diff --git a/Package.swift b/Package.swift index a27eaa22..fe0fc29f 100644 --- a/Package.swift +++ b/Package.swift @@ -30,15 +30,20 @@ var package = Package( // Core Library .target( name: "ArgumentParser", - dependencies: ["ArgumentParserToolInfo"], + dependencies: ["ArgumentParserToolInfo", "ArgumentParserOpenCLI"], exclude: ["CMakeLists.txt"]), .target( name: "ArgumentParserTestHelpers", - dependencies: ["ArgumentParser", "ArgumentParserToolInfo"], + dependencies: [ + "ArgumentParser", "ArgumentParserToolInfo", "ArgumentParserOpenCLI", + ], exclude: ["CMakeLists.txt"]), .target( name: "ArgumentParserToolInfo", exclude: ["CMakeLists.txt"]), + .target( + name: "ArgumentParserOpenCLI", + ), // Plugins .plugin( @@ -117,7 +122,9 @@ var package = Package( exclude: ["Examples"]), .testTarget( name: "ArgumentParserUnitTests", - dependencies: ["ArgumentParser", "ArgumentParserTestHelpers"], + dependencies: [ + "ArgumentParser", "ArgumentParserTestHelpers", "ArgumentParserOpenCLI", + ], exclude: ["CMakeLists.txt", "Snapshots"]), ] ) diff --git a/Package@swift-5.8.swift b/Package@swift-5.8.swift index fb860aff..50bffab8 100644 --- a/Package@swift-5.8.swift +++ b/Package@swift-5.8.swift @@ -30,16 +30,21 @@ var package = Package( // Core Library .target( name: "ArgumentParser", - dependencies: ["ArgumentParserToolInfo"], + dependencies: ["ArgumentParserToolInfo", "ArgumentParserOpenCLI"], exclude: ["CMakeLists.txt"], swiftSettings: [.enableExperimentalFeature("StrictConcurrency")]), .target( name: "ArgumentParserTestHelpers", - dependencies: ["ArgumentParser", "ArgumentParserToolInfo"], + dependencies: [ + "ArgumentParser", "ArgumentParserToolInfo", "ArgumentParserOpenCLI", + ], exclude: ["CMakeLists.txt"]), .target( name: "ArgumentParserToolInfo", exclude: ["CMakeLists.txt"]), + .target( + name: "ArgumentParserOpenCLI" + ), // Plugins .plugin( @@ -118,7 +123,9 @@ var package = Package( exclude: ["Examples"]), .testTarget( name: "ArgumentParserUnitTests", - dependencies: ["ArgumentParser", "ArgumentParserTestHelpers"], + dependencies: [ + "ArgumentParser", "ArgumentParserTestHelpers", "ArgumentParserOpenCLI", + ], exclude: ["CMakeLists.txt", "Snapshots"]), ] ) diff --git a/Schemas/opencli-v0.1.json b/Schemas/opencli-v0.1.json new file mode 100644 index 00000000..f5ac3514 --- /dev/null +++ b/Schemas/opencli-v0.1.json @@ -0,0 +1,378 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "OpenCLI.json", + "type": "object", + "properties": { + "opencli": { + "type": "string", + "description": "The OpenCLI version number" + }, + "info": { + "$ref": "#/$defs/CliInfo", + "description": "Information about the CLI" + }, + "conventions": { + "$ref": "#/$defs/Conventions", + "description": "The conventions used by the CLI" + }, + "arguments": { + "type": "array", + "items": { + "$ref": "#/$defs/Argument" + }, + "description": "Root command arguments" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/$defs/Option" + }, + "description": "Root command options" + }, + "commands": { + "type": "array", + "items": { + "$ref": "#/$defs/Command" + }, + "description": "Root command sub commands" + }, + "exitCodes": { + "type": "array", + "items": { + "$ref": "#/$defs/ExitCode" + }, + "description": "Root command exit codes" + }, + "examples": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Examples of how to use the CLI" + }, + "interactive": { + "type": "boolean", + "description": "Indicates whether or not the command requires interactive input" + }, + "metadata": { + "type": "array", + "items": { + "$ref": "#/$defs/Metadata" + }, + "description": "Custom metadata" + } + }, + "required": [ + "opencli", + "info" + ], + "description": "The OpenCLI description", + "$defs": { + "CliInfo": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "The application title" + }, + "summary": { + "type": "string", + "description": "A short summary of the application" + }, + "description": { + "type": "string", + "description": "A description of the application" + }, + "contact": { + "$ref": "#/$defs/Contact", + "description": "The contact information" + }, + "license": { + "$ref": "#/$defs/License", + "description": "The application license" + }, + "version": { + "type": "string", + "description": "The application version" + } + }, + "required": [ + "title", + "version" + ] + }, + "Conventions": { + "type": "object", + "properties": { + "groupOptions": { + "type": "boolean", + "default": true, + "description": "Whether or not grouping of short options are allowed" + }, + "optionSeparator": { + "type": "string", + "default": " ", + "description": "The option argument separator" + } + } + }, + "Argument": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The argument name" + }, + "required": { + "type": "boolean", + "description": "Whether or not the argument is required" + }, + "arity": { + "$ref": "#/$defs/Arity", + "description": "The argument arity. Arity defines the minimum and maximum number of argument values" + }, + "acceptedValues": { + "type": "array", + "items": { + "type": "string" + }, + "description": "A list of accepted values" + }, + "group": { + "type": "string", + "description": "The argument group" + }, + "description": { + "type": "string", + "description": "The argument description" + }, + "hidden": { + "type": "boolean", + "default": false, + "description": "Whether or not the argument is hidden" + }, + "metadata": { + "type": "array", + "items": { + "$ref": "#/$defs/Metadata" + }, + "description": "Custom metadata" + } + }, + "required": [ + "name" + ] + }, + "Option": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The option name" + }, + "required": { + "type": "boolean", + "description": "Whether or not the option is required" + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true, + "description": "The option's aliases" + }, + "arguments": { + "type": "array", + "items": { + "$ref": "#/$defs/Argument" + }, + "description": "The option's arguments" + }, + "group": { + "type": "string", + "description": "The option group" + }, + "description": { + "type": "string", + "description": "The option description" + }, + "recursive": { + "type": "boolean", + "default": false, + "description": "Specifies whether the option is accessible from the immediate parent command and, recursively, from its subcommands" + }, + "hidden": { + "type": "boolean", + "default": false, + "description": "Whether or not the option is hidden" + }, + "metadata": { + "type": "array", + "items": { + "$ref": "#/$defs/Metadata" + }, + "description": "Custom metadata" + } + }, + "required": [ + "name" + ] + }, + "Command": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The command name" + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true, + "description": "The command aliases" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/$defs/Option" + }, + "description": "The command options" + }, + "arguments": { + "type": "array", + "items": { + "$ref": "#/$defs/Argument" + }, + "description": "The command arguments" + }, + "commands": { + "type": "array", + "items": { + "$ref": "#/$defs/Command" + }, + "description": "The command's sub commands" + }, + "exitCodes": { + "type": "array", + "items": { + "$ref": "#/$defs/ExitCode" + }, + "description": "The command's exit codes" + }, + "description": { + "type": "string", + "description": "The command description" + }, + "hidden": { + "type": "boolean", + "default": false, + "description": "Whether or not the command is hidden" + }, + "examples": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Examples of how to use the command" + }, + "interactive": { + "type": "boolean", + "description": "Indicate whether or not the command requires interactive input" + }, + "metadata": { + "type": "array", + "items": { + "$ref": "#/$defs/Metadata" + }, + "description": "Custom metadata" + } + }, + "required": [ + "name" + ] + }, + "ExitCode": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "description": "The exit code" + }, + "description": { + "type": "string", + "description": "The exit code description" + } + }, + "required": [ + "code" + ] + }, + "Metadata": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": {} + }, + "required": [ + "name" + ] + }, + "Contact": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The identifying name of the contact person/organization" + }, + "url": { + "type": "string", + "format": "uri", + "description": "The URI for the contact information. This MUST be in the form of a URI." + }, + "email": { + "$ref": "#/$defs/email", + "description": "The email address of the contact person/organization. This MUST be in the form of an email address." + } + }, + "description": "Contact information" + }, + "License": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The license name" + }, + "identifier": { + "type": "string", + "description": "The SPDX license identifier" + } + } + }, + "Arity": { + "type": "object", + "properties": { + "minimum": { + "type": "integer", + "minimum": 0, + "description": "The minimum number of values allowed" + }, + "maximum": { + "type": "integer", + "minimum": 0, + "description": "The maximum number of values allowed" + } + }, + "description": "Arity defines the minimum and maximum number of argument values" + }, + "email": { + "type": "string", + "pattern": ".+\\@.+\\..+" + } + } +} \ No newline at end of file diff --git a/Sources/ArgumentParser/CMakeLists.txt b/Sources/ArgumentParser/CMakeLists.txt index 77dc92e5..d8f7e7aa 100644 --- a/Sources/ArgumentParser/CMakeLists.txt +++ b/Sources/ArgumentParser/CMakeLists.txt @@ -39,6 +39,7 @@ add_library(ArgumentParser Usage/HelpCommand.swift Usage/HelpGenerator.swift Usage/MessageInfo.swift + Usage/OpenCLIv0_1Generator.swift Usage/UsageGenerator.swift Utilities/CollectionExtensions.swift @@ -61,6 +62,7 @@ set_target_properties(ArgumentParser PROPERTIES target_compile_options(ArgumentParser PRIVATE $<$:-enable-testing>) target_link_libraries(ArgumentParser PRIVATE + ArgumentParserOpenCLI ArgumentParserToolInfo) if(Foundation_FOUND) target_link_libraries(ArgumentParser PRIVATE diff --git a/Sources/ArgumentParser/Parsing/CommandParser.swift b/Sources/ArgumentParser/Parsing/CommandParser.swift index b3296d67..d383416a 100644 --- a/Sources/ArgumentParser/Parsing/CommandParser.swift +++ b/Sources/ArgumentParser/Parsing/CommandParser.swift @@ -134,6 +134,14 @@ extension CommandParser { throw CommandError( commandStack: commandStack, parserError: .dumpHelpRequested) } + // Look for dump-opencli flag for any supported version + for version in OpenCLIVersion.allCases { + guard !split.contains(Name.long(version.flagName)) else { + throw CommandError( + commandStack: commandStack, + parserError: .dumpOpenCLIRequested(version: version)) + } + } // Look for a version flag if any commands in the stack define a version if commandStack.contains(where: { !$0.configuration.version.isEmpty }) { diff --git a/Sources/ArgumentParser/Parsing/ParserError.swift b/Sources/ArgumentParser/Parsing/ParserError.swift index dd0c0e1b..3d4e4082 100644 --- a/Sources/ArgumentParser/Parsing/ParserError.swift +++ b/Sources/ArgumentParser/Parsing/ParserError.swift @@ -9,11 +9,22 @@ // //===----------------------------------------------------------------------===// +/// Represents supported OpenCLI schema versions. +public enum OpenCLIVersion: String, CaseIterable, Sendable { + // swift-format-ignore: AlwaysUseLowerCamelCase + case v0_1 = "v0.1" + + public var flagName: String { + "help-dump-opencli-\(self.rawValue)" + } +} + /// Gets thrown while parsing and will be handled by the error output generation. enum ParserError: Error { case helpRequested(visibility: ArgumentVisibility) case versionRequested case dumpHelpRequested + case dumpOpenCLIRequested(version: OpenCLIVersion) case completionScriptRequested(shell: String?) case completionScriptCustomResponse(String) diff --git a/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift b/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift index 3ed2ac26..bd826371 100644 --- a/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift +++ b/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift @@ -34,7 +34,7 @@ internal struct DumpHelpGenerator { extension BidirectionalCollection where Element == ParsableCommand.Type { /// Returns the ArgumentSet for the last command in this stack, including /// help and version flags, when appropriate. - fileprivate func allArguments() -> ArgumentSet { + internal func allArguments() -> ArgumentSet { guard var arguments = self.last.map({ ArgumentSet($0, visibility: .private, parent: nil) diff --git a/Sources/ArgumentParser/Usage/MessageInfo.swift b/Sources/ArgumentParser/Usage/MessageInfo.swift index 8bf4528c..90e3678d 100644 --- a/Sources/ArgumentParser/Usage/MessageInfo.swift +++ b/Sources/ArgumentParser/Usage/MessageInfo.swift @@ -37,6 +37,16 @@ enum MessageInfo { text: DumpHelpGenerator(commandStack: e.commandStack).rendered()) return + case .dumpOpenCLIRequested(let version): + let generatorText: String + switch version { + case .v0_1: + generatorText = OpenCLIv0_1Generator(commandStack: e.commandStack) + .rendered() + } + self = .help(text: generatorText) + return + case .versionRequested: let versionString = commandStack diff --git a/Sources/ArgumentParser/Usage/OpenCLIv0_1Generator.swift b/Sources/ArgumentParser/Usage/OpenCLIv0_1Generator.swift new file mode 100644 index 00000000..00f3d4b6 --- /dev/null +++ b/Sources/ArgumentParser/Usage/OpenCLIv0_1Generator.swift @@ -0,0 +1,208 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Argument Parser open source project +// +// Copyright (c) 2020 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +#if compiler(>=6.0) +internal import Foundation +internal import ArgumentParserOpenCLI +#else +import Foundation +import ArgumentParserOpenCLI +#endif + +internal struct OpenCLIv0_1Generator { + private var openCLI: OpenCLIv0_1 + + init(_ type: ParsableArguments.Type) { + self.init(commandStack: [type.asCommand]) + } + + init(commandStack: [ParsableCommand.Type]) { + self.openCLI = OpenCLIv0_1(commandStack: commandStack) + } + + func rendered() -> String { + do { + let encoder = Foundation.JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(self.openCLI) + return String(data: data, encoding: .utf8) ?? "{}" + } catch { + return "{\"error\": \"Failed to encode OpenCLI: \(error)\"}" + } + } +} + +extension OpenCLIv0_1 { + init(commandStack: [ParsableCommand.Type]) { + guard let rootCommand = commandStack.first else { + preconditionFailure("commandStack must not be empty") + } + + let config = rootCommand.configuration + let info = OpenCLIv0_1.CliInfo( + title: rootCommand._commandName, + version: config.version.isEmpty ? "1.0.0" : config.version, + summary: config.abstract.isEmpty ? nil : config.abstract, + description: config.discussion.isEmpty ? nil : config.discussion + ) + + let argumentSet = commandStack.allArguments() + let (options, arguments) = OpenCLIv0_1.extractOptionsAndArguments( + from: argumentSet) + + let commands = config.subcommands.compactMap { + subcommand -> OpenCLIv0_1.Command? in + OpenCLIv0_1.Command( + subcommand: subcommand, parentStack: commandStack) + } + + let conventions = OpenCLIv0_1.Conventions() + let conventionsToInclude = + conventions.hasNonDefaultValues ? conventions : nil + + self.init( + opencli: "0.1", + info: info, + conventions: conventionsToInclude, + arguments: arguments.isEmpty ? nil : arguments, + options: options.isEmpty ? nil : options, + commands: commands.isEmpty ? nil : commands + ) + } + + internal static func extractOptionsAndArguments(from argumentSet: ArgumentSet) + -> ([OpenCLIv0_1.Option], [OpenCLIv0_1.Argument]) + { + var options: [OpenCLIv0_1.Option] = [] + var arguments: [OpenCLIv0_1.Argument] = [] + + for argDef in argumentSet { + switch argDef.kind { + case .named: + if let option = OpenCLIv0_1.Option(from: argDef) { + options.append(option) + } + case .positional: + if let argument = OpenCLIv0_1.Argument(from: argDef) { + arguments.append(argument) + } + case .default: + break + } + } + + return (options, arguments) + } +} + +extension OpenCLIv0_1.Command { + init?(subcommand: ParsableCommand.Type, parentStack: [ParsableCommand.Type]) { + let config = subcommand.configuration + let commandStack = parentStack + [subcommand] + let argumentSet = commandStack.allArguments() + let (options, arguments) = OpenCLIv0_1.extractOptionsAndArguments( + from: argumentSet) + + let subcommands = config.subcommands.compactMap { + subSubcommand -> OpenCLIv0_1.Command? in + OpenCLIv0_1.Command( + subcommand: subSubcommand, parentStack: commandStack) + } + + self.init( + name: subcommand._commandName, + aliases: config.aliases.isEmpty ? nil : config.aliases, + options: options.isEmpty ? nil : options, + arguments: arguments.isEmpty ? nil : arguments, + commands: subcommands.isEmpty ? nil : subcommands, + description: config.abstract.isEmpty ? nil : config.abstract, + hidden: !config.shouldDisplay ? true : nil + ) + } +} + +extension OpenCLIv0_1.Option { + init?(from argDef: ArgumentDefinition) { + guard case .named = argDef.kind else { return nil } + + let names = argDef.names.map { $0.synopsisString } + guard let primaryName = names.first else { return nil } + + let aliases = Array(names.dropFirst()) + + // Extract arguments for this option if it takes values + var optionArguments: [OpenCLIv0_1.Argument]? = nil + switch argDef.update { + case .unary: + let argument = OpenCLIv0_1.Argument( + name: argDef.valueName, + required: !argDef.help.options.contains(.isOptional) ? true : nil, + description: argDef.help.abstract.isEmpty ? nil : argDef.help.abstract, + swiftArgumentParserDefaultValue: argDef.help.defaultValue + ) + optionArguments = [argument] + case .nullary: + break + } + + self.init( + name: primaryName, + required: !argDef.help.options.contains(.isOptional) ? true : nil, + aliases: aliases.isEmpty ? nil : aliases, + arguments: optionArguments, + description: argDef.help.abstract.isEmpty ? nil : argDef.help.abstract, + hidden: argDef.help.visibility.base != .default ? true : nil, + swiftArgumentParserRepeating: argDef.help.options.contains(.isRepeating) + ? true : nil, + swiftArgumentParserFile: { + switch argDef.completion.kind { + case .file(let extensions): + return OpenCLIv0_1.SwiftArgumentParserFile(extensions: extensions) + default: return nil + } + }(), + swiftArgumentParserDirectory: { + switch argDef.completion.kind { + case .directory: return true + default: return nil + } + }(), + swiftArgumentParserDefaultValue: argDef.help.defaultValue + ) + } +} + +extension OpenCLIv0_1.Argument { + init?(from argDef: ArgumentDefinition) { + guard case .positional = argDef.kind else { return nil } + + self.init( + name: argDef.valueName, + required: !argDef.help.options.contains(.isOptional) ? true : nil, + description: argDef.help.abstract.isEmpty ? nil : argDef.help.abstract, + hidden: argDef.help.visibility.base != .default ? true : nil, + swiftArgumentParserFile: { + switch argDef.completion.kind { + case .file(let extensions): + return OpenCLIv0_1.SwiftArgumentParserFile(extensions: extensions) + default: return nil + } + }(), + swiftArgumentParserDirectory: { + switch argDef.completion.kind { + case .directory: return true + default: return nil + } + }(), + swiftArgumentParserDefaultValue: argDef.help.defaultValue + ) + } +} diff --git a/Sources/ArgumentParser/Usage/UsageGenerator.swift b/Sources/ArgumentParser/Usage/UsageGenerator.swift index 5adc83fe..e6eb2262 100644 --- a/Sources/ArgumentParser/Usage/UsageGenerator.swift +++ b/Sources/ArgumentParser/Usage/UsageGenerator.swift @@ -176,7 +176,8 @@ extension ErrorMessageGenerator { func makeErrorMessage() -> String? { switch error { case .helpRequested, .versionRequested, .completionScriptRequested, - .completionScriptCustomResponse, .dumpHelpRequested: + .completionScriptCustomResponse, .dumpHelpRequested, + .dumpOpenCLIRequested: return nil case .unsupportedShell(let shell?): diff --git a/Sources/ArgumentParserOpenCLI/CMakeLists.txt b/Sources/ArgumentParserOpenCLI/CMakeLists.txt new file mode 100644 index 00000000..6f7252c7 --- /dev/null +++ b/Sources/ArgumentParserOpenCLI/CMakeLists.txt @@ -0,0 +1,11 @@ +add_library(ArgumentParserOpenCLI + OpenCLIv0_1.swift) +# NOTE: workaround for CMake not setting up include flags yet +set_target_properties(ArgumentParserOpenCLI PROPERTIES + INTERFACE_INCLUDE_DIRECTORIES ${CMAKE_Swift_MODULE_DIRECTORY}) +target_compile_options(ArgumentParserOpenCLI PRIVATE + $<$:-enable-testing>) + + +_install_target(ArgumentParserOpenCLI) +set_property(GLOBAL APPEND PROPERTY ArgumentParser_EXPORTS ArgumentParserOpenCLI) \ No newline at end of file diff --git a/Sources/ArgumentParserOpenCLI/OpenCLIv0_1.swift b/Sources/ArgumentParserOpenCLI/OpenCLIv0_1.swift new file mode 100644 index 00000000..63c449ed --- /dev/null +++ b/Sources/ArgumentParserOpenCLI/OpenCLIv0_1.swift @@ -0,0 +1,344 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Argument Parser open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +public struct OpenCLIv0_1: Codable, Equatable { + public let opencli: String + public let info: CliInfo + public let conventions: Conventions? + public let arguments: [Argument]? + public let options: [Option]? + public let commands: [Command]? + public let exitCodes: [ExitCode]? + public let examples: [String]? + public let interactive: Bool? + public let metadata: [Metadata]? + + public init( + opencli: String, info: CliInfo, conventions: Conventions? = nil, + arguments: [Argument]? = nil, options: [Option]? = nil, + commands: [Command]? = nil, exitCodes: [ExitCode]? = nil, + examples: [String]? = nil, interactive: Bool? = nil, + metadata: [Metadata]? = nil + ) { + self.opencli = opencli + self.info = info + self.conventions = conventions + self.arguments = arguments + self.options = options + self.commands = commands + self.exitCodes = exitCodes + self.examples = examples + self.interactive = interactive + self.metadata = metadata + } + + public struct CliInfo: Codable, Equatable { + public let title: String + public let version: String + public let summary: String? + public let description: String? + public let contact: Contact? + public let license: License? + + public init( + title: String, version: String, summary: String? = nil, + description: String? = nil, contact: Contact? = nil, + license: License? = nil + ) { + self.title = title + self.version = version + self.summary = summary + self.description = description + self.contact = contact + self.license = license + } + } + + public struct Conventions: Codable, Equatable { + public let groupOptions: Bool? + public let optionSeparator: String? + + public init(groupOptions: Bool? = nil, optionSeparator: String? = nil) { + self.groupOptions = groupOptions + self.optionSeparator = optionSeparator + } + + /// Returns true if this Conventions object has any non-default values. + public var hasNonDefaultValues: Bool { + (groupOptions != nil && groupOptions != true) + || (optionSeparator != nil && optionSeparator != " ") + } + } + + public struct Argument: Codable, Equatable { + public let name: String + public let required: Bool? + public let arity: Arity? + public let acceptedValues: [String]? + public let group: String? + public let description: String? + public let hidden: Bool? + public let metadata: [Metadata]? + public let swiftArgumentParserFile: SwiftArgumentParserFile? + public let swiftArgumentParserDirectory: Bool? + public let swiftArgumentParserDefaultValue: String? + + public init( + name: String, required: Bool? = nil, arity: Arity? = nil, + acceptedValues: [String]? = nil, group: String? = nil, + description: String? = nil, hidden: Bool? = nil, + metadata: [Metadata]? = nil, + swiftArgumentParserFile: SwiftArgumentParserFile? = nil, + swiftArgumentParserDirectory: Bool? = nil, + swiftArgumentParserDefaultValue: String? = nil + ) { + self.name = name + self.required = required + self.arity = arity + self.acceptedValues = acceptedValues + self.group = group + self.description = description + self.hidden = hidden + self.metadata = metadata + self.swiftArgumentParserFile = swiftArgumentParserFile + self.swiftArgumentParserDirectory = swiftArgumentParserDirectory + self.swiftArgumentParserDefaultValue = swiftArgumentParserDefaultValue + } + } + + public struct Option: Codable, Equatable { + public let name: String + public let required: Bool? + public let aliases: [String]? + public let arguments: [Argument]? + public let group: String? + public let description: String? + public let recursive: Bool? + public let hidden: Bool? + public let metadata: [Metadata]? + public let swiftArgumentParserRepeating: Bool? + public let swiftArgumentParserFile: SwiftArgumentParserFile? + public let swiftArgumentParserDirectory: Bool? + public let swiftArgumentParserDefaultValue: String? + + public init( + name: String, required: Bool? = nil, aliases: [String]? = nil, + arguments: [Argument]? = nil, group: String? = nil, + description: String? = nil, recursive: Bool? = false, + hidden: Bool? = nil, + metadata: [Metadata]? = nil, swiftArgumentParserRepeating: Bool? = nil, + swiftArgumentParserFile: SwiftArgumentParserFile? = nil, + swiftArgumentParserDirectory: Bool? = nil, + swiftArgumentParserDefaultValue: String? = nil + ) { + self.name = name + self.required = required + self.aliases = aliases + self.arguments = arguments + self.group = group + self.description = description + self.recursive = recursive + self.hidden = hidden + self.metadata = metadata + self.swiftArgumentParserRepeating = swiftArgumentParserRepeating + self.swiftArgumentParserFile = swiftArgumentParserFile + self.swiftArgumentParserDirectory = swiftArgumentParserDirectory + self.swiftArgumentParserDefaultValue = swiftArgumentParserDefaultValue + } + } + + public struct Command: Codable, Equatable { + public let name: String + public let aliases: [String]? + public let options: [Option]? + public let arguments: [Argument]? + public let commands: [Command]? + public let exitCodes: [ExitCode]? + public let description: String? + public let hidden: Bool? + public let examples: [String]? + public let interactive: Bool? + public let metadata: [Metadata]? + + public init( + name: String, aliases: [String]? = nil, options: [Option]? = nil, + arguments: [Argument]? = nil, commands: [Command]? = nil, + exitCodes: [ExitCode]? = nil, description: String? = nil, + hidden: Bool? = nil, examples: [String]? = nil, + interactive: Bool? = nil, + metadata: [Metadata]? = nil + ) { + self.name = name + self.aliases = aliases + self.options = options + self.arguments = arguments + self.commands = commands + self.exitCodes = exitCodes + self.description = description + self.hidden = hidden + self.examples = examples + self.interactive = interactive + self.metadata = metadata + } + } + + public struct SwiftArgumentParserFile: Codable, Equatable { + public let extensions: [String] + + public init(extensions: [String]) { + self.extensions = extensions + } + } + + public struct ExitCode: Codable, Equatable { + public let code: Int + public let description: String? + + public init(code: Int, description: String? = nil) { + self.code = code + self.description = description + } + } + + public struct Metadata: Codable, Equatable { + public let name: String + public let value: AnyCodable? + + public init(name: String, value: AnyCodable? = nil) { + self.name = name + self.value = value + } + } + + public struct Contact: Codable, Equatable { + public let name: String? + public let url: String? + public let email: String? + + public init(name: String? = nil, url: String? = nil, email: String? = nil) { + self.name = name + self.url = url + self.email = email + } + } + + public struct License: Codable, Equatable { + public let name: String? + public let identifier: String? + + public init(name: String? = nil, identifier: String? = nil) { + self.name = name + self.identifier = identifier + } + } + + public struct Arity: Codable, Equatable { + public let minimum: Int? + public let maximum: Int? + + public init(minimum: Int? = nil, maximum: Int? = nil) { + self.minimum = minimum + self.maximum = maximum + } + } + + public struct AnyCodable: Codable, Equatable { + public let value: Any + + public init(_ value: T?) { + self.value = value ?? () + } + + public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool { + switch (lhs.value, rhs.value) { + case let (lhs, rhs) as (Bool, Bool): + return lhs == rhs + case let (lhs, rhs) as (Int, Int): + return lhs == rhs + case let (lhs, rhs) as (Double, Double): + return lhs == rhs + case let (lhs, rhs) as (String, String): + return lhs == rhs + case let (lhs, rhs) as ([Any], [Any]): + guard lhs.count == rhs.count else { return false } + return zip(lhs, rhs).allSatisfy { + AnyCodable($0).value as? AnyHashable == AnyCodable($1).value + as? AnyHashable + } + case let (lhs, rhs) as ([String: Any], [String: Any]): + guard lhs.count == rhs.count else { return false } + return lhs.allSatisfy { key, value in + guard let rhsValue = rhs[key] else { return false } + return AnyCodable(value).value as? AnyHashable == AnyCodable(rhsValue) + .value as? AnyHashable + } + case (is ResultNil, is ResultNil): + return true + default: + // Fallback to AnyHashable if possible + return lhs.value as? AnyHashable == rhs.value as? AnyHashable + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self.init(ResultNil()) + } else if let bool = try? container.decode(Bool.self) { + self.init(bool) + } else if let int = try? container.decode(Int.self) { + self.init(int) + } else if let double = try? container.decode(Double.self) { + self.init(double) + } else if let string = try? container.decode(String.self) { + self.init(string) + } else if let array = try? container.decode([AnyCodable].self) { + self.init(array.map { $0.value }) + } else if let dictionary = try? container.decode( + [String: AnyCodable].self) + { + self.init(dictionary.mapValues { $0.value }) + } else { + throw DecodingError.dataCorruptedError( + in: container, debugDescription: "AnyCodable value cannot be decoded") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch value { + case is ResultNil: + try container.encodeNil() + case let bool as Bool: + try container.encode(bool) + case let int as Int: + try container.encode(int) + case let double as Double: + try container.encode(double) + case let string as String: + try container.encode(string) + case let array as [Any]: + try container.encode(array.map(AnyCodable.init)) + case let dictionary as [String: Any]: + try container.encode(dictionary.mapValues(AnyCodable.init)) + default: + let context = EncodingError.Context( + codingPath: container.codingPath, + debugDescription: "AnyCodable value cannot be encoded") + throw EncodingError.invalidValue(value, context) + } + } + } + + private struct ResultNil: Codable, Equatable {} +} diff --git a/Sources/ArgumentParserTestHelpers/CMakeLists.txt b/Sources/ArgumentParserTestHelpers/CMakeLists.txt index 3b6d74fd..f46f3d4d 100644 --- a/Sources/ArgumentParserTestHelpers/CMakeLists.txt +++ b/Sources/ArgumentParserTestHelpers/CMakeLists.txt @@ -4,6 +4,7 @@ set_target_properties(ArgumentParserTestHelpers PROPERTIES INTERFACE_INCLUDE_DIRECTORIES ${CMAKE_Swift_MODULE_DIRECTORY}) target_link_libraries(ArgumentParserTestHelpers PUBLIC ArgumentParser + ArgumentParserOpenCLI ArgumentParserToolInfo) if(Foundation_FOUND) target_link_libraries(ArgumentParserTestHelpers PUBLIC diff --git a/Sources/ArgumentParserTestHelpers/TestHelpers.swift b/Sources/ArgumentParserTestHelpers/TestHelpers.swift index bb8a6575..4b71441b 100644 --- a/Sources/ArgumentParserTestHelpers/TestHelpers.swift +++ b/Sources/ArgumentParserTestHelpers/TestHelpers.swift @@ -10,6 +10,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser +import ArgumentParserOpenCLI import ArgumentParserToolInfo import XCTest @@ -630,4 +631,61 @@ extension XCTest { file: file, line: line) } + + public func assertDumpOpenCLI( + type: T.Type, + record: Bool = false, + test: StaticString = #function, + file: StaticString = #filePath, + line: UInt = #line + ) throws { + let actual: String + do { + _ = try T.parse(["--help-dump-opencli-v0.1"]) + XCTFail( + "Expected parsing to fail with OpenCLI dump request", file: file, + line: line) + return + } catch { + actual = T.fullMessage(for: error) + } + + let expected = try self.assertSnapshot( + actual: actual, + extension: "json", + record: record, + test: test, + file: file, + line: line) + + guard let expected else { return } + + try AssertJSONEqualFromString( + actual: actual, + expected: expected, + for: OpenCLIv0_1.self, + file: file, + line: line) + } + + public func assertDumpOpenCLI( + command: String, + record: Bool = false, + test: StaticString = #function, + file: StaticString = #filePath, + line: UInt = #line + ) throws { + let actual = try AssertExecuteCommand( + command: command + " --help-dump-opencli-v0.1", + expected: nil, + file: file, + line: line) + try self.assertSnapshot( + actual: actual, + extension: "json", + record: record, + test: test, + file: file, + line: line) + } } diff --git a/Sources/CMakeLists.txt b/Sources/CMakeLists.txt index b1772e67..ff100c31 100644 --- a/Sources/CMakeLists.txt +++ b/Sources/CMakeLists.txt @@ -1,4 +1,5 @@ add_subdirectory(ArgumentParser) +add_subdirectory(ArgumentParserOpenCLI) add_subdirectory(ArgumentParserToolInfo) if(BUILD_TESTING) add_subdirectory(ArgumentParserTestHelpers) diff --git a/Tests/ArgumentParserEndToEndTests/OpenCLIDumpHelpGenerationTests.swift b/Tests/ArgumentParserEndToEndTests/OpenCLIDumpHelpGenerationTests.swift new file mode 100644 index 00000000..3e9132f4 --- /dev/null +++ b/Tests/ArgumentParserEndToEndTests/OpenCLIDumpHelpGenerationTests.swift @@ -0,0 +1,500 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Argument Parser open source project +// +// Copyright (c) 2020 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +import ArgumentParserTestHelpers +import XCTest + +@testable import ArgumentParser +@testable import ArgumentParserOpenCLI + +final class OpenCLIDumpHelpGenerationTests: XCTestCase { + public func testADumpOpenCLI() throws { + try assertDumpOpenCLI(type: A.self) + } + + public func testBDumpOpenCLI() throws { + try assertDumpOpenCLI(type: B.self) + } + + public func testCDumpOpenCLI() throws { + try assertDumpOpenCLI(type: C.self) + } + + func testMathDumpOpenCLI() throws { + try assertDumpOpenCLI(command: "math") + } + + func testMathAddDumpOpenCLI() throws { + try assertDumpOpenCLI(command: "math add") + } + + func testMathMultiplyDumpOpenCLI() throws { + try assertDumpOpenCLI(command: "math multiply") + } + + func testMathStatsDumpOpenCLI() throws { + try assertDumpOpenCLI(command: "math stats") + } + + func testSimpleCommandDumpOpenCLI() throws { + try assertDumpOpenCLI(type: SimpleCommand.self) + } + + func testNestedCommandDumpOpenCLI() throws { + try assertDumpOpenCLI(type: ParentCommand.self) + } + + func testCommandWithOptionsDumpOpenCLI() throws { + try assertDumpOpenCLI(type: CommandWithOptions.self) + } + + func testRepeatingOptionProperty() throws { + // Test that swiftArgumentParserRepeating is set correctly for repeating options + let actual: String + do { + _ = try CommandWithOptions.parse(["--help-dump-opencli-v0.1"]) + XCTFail("Expected parsing to fail with OpenCLI dump request") + return + } catch { + actual = CommandWithOptions.fullMessage(for: error) + } + + // Parse the JSON output + let jsonData = actual.data(using: .utf8)! + let openCLI = try JSONDecoder().decode(OpenCLIv0_1.self, from: jsonData) + + // Find the repeating option + guard let options = openCLI.options else { + XCTFail("Expected options to be present") + return + } + + // Find the items option (which uses .upToNextOption parsing) + let itemsOption = options.first { $0.name == "--items" } + XCTAssertNotNil(itemsOption, "Expected to find --items option") + XCTAssertEqual( + itemsOption?.swiftArgumentParserRepeating, true, + "Expected items option to have swiftArgumentParserRepeating set to true") + + // Find a non-repeating option to verify it doesn't have the property set + let configOption = options.first { $0.name == "-c" } + XCTAssertNotNil(configOption, "Expected to find -c option") + XCTAssertNil( + configOption?.swiftArgumentParserRepeating, + "Expected non-repeating option to have swiftArgumentParserRepeating as nil" + ) + } + + func testOpenCLIJSONStructure() throws { + // Test that the JSON structure matches OpenCLI schema + let actual: String + do { + _ = try SimpleCommand.parse(["--help-dump-opencli-v0.1"]) + XCTFail("Expected parsing to fail with OpenCLI dump request") + return + } catch { + actual = SimpleCommand.fullMessage(for: error) + } + + // Parse the JSON to validate structure + let jsonData = actual.data(using: .utf8)! + let openCLI = try JSONDecoder().decode(OpenCLIv0_1.self, from: jsonData) + + // Validate required fields + XCTAssertEqual(openCLI.opencli, "0.1") + XCTAssertEqual(openCLI.info.title, "simple") + XCTAssertEqual(openCLI.info.version, "1.0.0") + XCTAssertEqual(openCLI.info.summary, "A simple command for testing") + + // Validate options + XCTAssertNotNil(openCLI.options) + let options = openCLI.options! + XCTAssertTrue( + options.contains { $0.name == "--verbose" || $0.name == "-v" }) + XCTAssertTrue(options.contains { $0.name == "--input" || $0.name == "-i" }) + + // Validate arguments + XCTAssertNotNil(openCLI.arguments) + let arguments = openCLI.arguments! + XCTAssertTrue(arguments.contains { $0.name == "output" }) + } + + func testNestedCommandStructure() throws { + // Test nested command structure + let actual: String + do { + _ = try ParentCommand.parse(["--help-dump-opencli-v0.1"]) + XCTFail("Expected parsing to fail with OpenCLI dump request") + return + } catch { + actual = ParentCommand.fullMessage(for: error) + } + + let jsonData = actual.data(using: .utf8)! + let openCLI = try JSONDecoder().decode(OpenCLIv0_1.self, from: jsonData) + + // Validate parent command + XCTAssertEqual(openCLI.info.title, "parent") + XCTAssertEqual(openCLI.info.summary, "A parent command with subcommands") + + // Validate subcommands exist + XCTAssertNotNil(openCLI.commands) + let commands = openCLI.commands! + XCTAssertTrue(commands.contains { $0.name == "sub" }) + + // Validate subcommand structure + let subCommand = commands.first { $0.name == "sub" }! + XCTAssertEqual(subCommand.description, "A subcommand") + XCTAssertNotNil(subCommand.options) + } + + func testFileDirectoryCompletionProperties() throws { + // Test that swiftArgumentParserFile and swiftArgumentParserDirectory are set correctly + let actual: String + do { + _ = try CommandWithFileCompletion.parse(["--help-dump-opencli-v0.1"]) + XCTFail("Expected parsing to fail with OpenCLI dump request") + return + } catch { + actual = CommandWithFileCompletion.fullMessage(for: error) + } + + // Parse the JSON output + let jsonData = actual.data(using: .utf8)! + let openCLI = try JSONDecoder().decode(OpenCLIv0_1.self, from: jsonData) + + // Find the file option + guard let options = openCLI.options else { + XCTFail("Expected options to be present") + return + } + + let fileOption = options.first { $0.name == "--file" } + XCTAssertNotNil(fileOption, "Expected to find --file option") + XCTAssertNotNil( + fileOption?.swiftArgumentParserFile, + "Expected file option to have swiftArgumentParserFile set") + XCTAssertNil( + fileOption?.swiftArgumentParserDirectory, + "Expected file option to have swiftArgumentParserDirectory as nil") + + // Find the directory option + let dirOption = options.first { $0.name == "--dir" } + XCTAssertNotNil(dirOption, "Expected to find --dir option") + XCTAssertEqual( + dirOption?.swiftArgumentParserDirectory, true, + "Expected directory option to have swiftArgumentParserDirectory set to true" + ) + XCTAssertNil( + dirOption?.swiftArgumentParserFile, + "Expected directory option to have swiftArgumentParserFile as nil") + + // Find a regular option to verify it doesn't have completion properties set + let regularOption = options.first { $0.name == "--regular" } + XCTAssertNotNil(regularOption, "Expected to find --regular option") + XCTAssertNil( + regularOption?.swiftArgumentParserFile, + "Expected regular option to have swiftArgumentParserFile as nil") + XCTAssertNil( + regularOption?.swiftArgumentParserDirectory, + "Expected regular option to have swiftArgumentParserDirectory as nil") + + // Check arguments + guard let arguments = openCLI.arguments else { + XCTFail("Expected arguments to be present") + return + } + + let fileArg = arguments.first { $0.name == "input-file" } + XCTAssertNotNil(fileArg, "Expected to find input-file argument") + XCTAssertNotNil( + fileArg?.swiftArgumentParserFile, + "Expected file argument to have swiftArgumentParserFile set") + XCTAssertNil( + fileArg?.swiftArgumentParserDirectory, + "Expected file argument to have swiftArgumentParserDirectory as nil") + + let dirArg = arguments.first { $0.name == "output-dir" } + XCTAssertNotNil(dirArg, "Expected to find output-dir argument") + XCTAssertEqual( + dirArg?.swiftArgumentParserDirectory, true, + "Expected directory argument to have swiftArgumentParserDirectory set to true" + ) + XCTAssertNil( + dirArg?.swiftArgumentParserFile, + "Expected directory argument to have swiftArgumentParserFile as nil") + } + + func testDefaultValueProperties() throws { + // Test that swiftArgumentParserDefaultValue is set correctly for arguments and options with defaults + let actual: String + do { + _ = try CommandWithDefaultValues.parse(["--help-dump-opencli-v0.1"]) + XCTFail("Expected parsing to fail with OpenCLI dump request") + return + } catch { + actual = CommandWithDefaultValues.fullMessage(for: error) + } + + // Parse the JSON output + let jsonData = actual.data(using: .utf8)! + let openCLI = try JSONDecoder().decode(OpenCLIv0_1.self, from: jsonData) + + // Find the option with default value + guard let options = openCLI.options else { + XCTFail("Expected options to be present") + return + } + + let countOption = options.first { $0.name == "--count" } + XCTAssertNotNil(countOption, "Expected to find --count option") + XCTAssertEqual( + countOption?.swiftArgumentParserDefaultValue, "5", + "Expected count option to have swiftArgumentParserDefaultValue set to '5'" + ) + + let nameOption = options.first { $0.name == "--name" } + XCTAssertNotNil(nameOption, "Expected to find --name option") + XCTAssertEqual( + nameOption?.swiftArgumentParserDefaultValue, "default", + "Expected name option to have swiftArgumentParserDefaultValue set to 'default'" + ) + + let verboseOption = options.first { $0.name == "--verbose" } + XCTAssertNotNil(verboseOption, "Expected to find --verbose option") + XCTAssertEqual( + verboseOption?.swiftArgumentParserDefaultValue, nil, + "Expected verbose option to have swiftArgumentParserDefaultValue set to nil" + ) + + // Find an option without default to verify it doesn't have the property set + let requiredOption = options.first { $0.name == "--required-option" } + XCTAssertNotNil(requiredOption, "Expected to find --required-option") + XCTAssertNil( + requiredOption?.swiftArgumentParserDefaultValue, + "Expected required option to have swiftArgumentParserDefaultValue as nil") + + // Check arguments + guard let arguments = openCLI.arguments else { + XCTFail("Expected arguments to be present") + return + } + + let outputArg = arguments.first { $0.name == "output" } + XCTAssertNotNil(outputArg, "Expected to find output argument") + XCTAssertEqual( + outputArg?.swiftArgumentParserDefaultValue, "output.txt", + "Expected output argument to have swiftArgumentParserDefaultValue set to 'output.txt'" + ) + + let inputArg = arguments.first { $0.name == "input" } + XCTAssertNotNil(inputArg, "Expected to find input argument") + XCTAssertNil( + inputArg?.swiftArgumentParserDefaultValue, + "Expected required input argument to have swiftArgumentParserDefaultValue as nil" + ) + } +} + +extension OpenCLIDumpHelpGenerationTests { + struct A: ParsableCommand { + enum TestEnum: String, CaseIterable, ExpressibleByArgument { + case a = "one" + case b = "two" + case c = "three" + } + + @Option + var enumeratedOption: TestEnum + + @Option + var enumeratedOptionWithDefaultValue: TestEnum = .b + + @Option + var noHelpOption: Int + + @Option(help: "int value option") + var intOption: Int + + @Option(help: "int value option with default value") + var intOptionWithDefaultValue: Int = 0 + + @Argument + var arg: Int + + @Argument(help: "argument with help") + var argWithHelp: Int + + @Argument(help: "argument with default value") + var argWithDefaultValue: Int = 1 + } + + struct Options: ParsableArguments { + @Flag + var verbose = false + + @Option + var name: String + } + + struct B: ParsableCommand { + @OptionGroup(title: "Other") + var options: Options + } + + struct C: ParsableCommand { + static let configuration = CommandConfiguration(shouldDisplay: false) + + enum Color: String, CaseIterable, ExpressibleByArgument { + case blue + case red + case yellow + + var defaultValueDescription: String { + switch self { + case .blue: + return "A blue color, like the sky!" + case .red: + return "A red color, like a rose!" + case .yellow: + return "A yellow color, like the sun!" + } + } + } + + @Option(help: "A color to select.") + var color: Color + + @Option(help: "Another color to select!") + var defaultColor: Color = .red + + @Option(help: "An optional color.") + var opt: Color? + + @Option( + help: .init( + discussion: + "A preamble for the list of values in the discussion section.")) + var extra: Color + + @Option(help: .init(discussion: "A discussion.")) + var discussion: String + } + + struct SimpleCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "simple", + abstract: "A simple command for testing", + version: "1.0.0" + ) + + @Flag(name: [.short, .long], help: "Show verbose output") + var verbose: Bool = false + + @Option(name: [.short, .long], help: "Input file path") + var input: String? + + @Argument(help: "Output file path") + var output: String + } + + struct SubCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "sub", + abstract: "A subcommand" + ) + + @Option(help: "Sub option") + var value: Int = 42 + } + + struct ParentCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "parent", + abstract: "A parent command with subcommands", + subcommands: [SubCommand.self] + ) + + @Flag(help: "Global flag") + var global: Bool = false + } + + struct CommandWithOptions: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "options", + abstract: "Command with various option types" + ) + + @Flag(name: .shortAndLong, help: "Help flag") + var help: Bool = false + + @Option( + name: [.customShort("c"), .customLong("config")], + help: "Configuration file") + var configFile: String? + + @Option(parsing: .upToNextOption, help: "Repeating option") + var items: [String] = [] + + @Argument(help: "Required argument") + var required: String + + @Argument(help: "Optional argument") + var optional: String? + } + + struct CommandWithFileCompletion: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "file-completion", + abstract: "Command with file and directory completion" + ) + + @Option(help: "File option", completion: .file()) + var file: String? + + @Option(help: "Directory option", completion: .directory) + var dir: String? + + @Option(help: "Regular option without completion") + var regular: String? + + @Argument(help: "Input file", completion: .file()) + var inputFile: String + + @Argument(help: "Output directory", completion: .directory) + var outputDir: String + } + + struct CommandWithDefaultValues: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "default-values", + abstract: "Command with various default values" + ) + + @Option(help: "Count value with default") + var count: Int = 5 + + @Option(help: "Name with default value") + var name: String = "default" + + @Flag(help: "Verbose flag with default") + var verbose: Bool = false + + @Option(help: "Required option without default") + var requiredOption: String + + @Argument(help: "Required input argument") + var input: String + + @Argument(help: "Output file with default") + var output: String = "output.txt" + } +} diff --git a/Tests/ArgumentParserEndToEndTests/OpenCLIEndToEndTests.swift b/Tests/ArgumentParserEndToEndTests/OpenCLIEndToEndTests.swift new file mode 100644 index 00000000..f6ed046f --- /dev/null +++ b/Tests/ArgumentParserEndToEndTests/OpenCLIEndToEndTests.swift @@ -0,0 +1,98 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Argument Parser open source project +// +// Copyright (c) 2020 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +import ArgumentParser +import ArgumentParserTestHelpers +import XCTest + +@testable import ArgumentParserOpenCLI + +final class OpenCLIEndToEndTests: XCTestCase {} + +// MARK: OpenCLI Flag Recognition + +private struct TestCommand: ParsableCommand { + @Flag var verbose: Bool = false + @ArgumentParser.Option var name: String = "test" + @ArgumentParser.Argument var input: String +} + +extension OpenCLIEndToEndTests { + func testOpenCLIFlagRecognition() throws { + // Test that the flag is recognized and triggers the appropriate error + do { + _ = try TestCommand.parse(["--help-dump-opencli-v0.1"]) + XCTFail("Expected parsing to fail with OpenCLI dump request") + } catch { + let message = TestCommand.fullMessage(for: error) + + // Verify it's JSON output containing OpenCLI structure + XCTAssertTrue(message.contains("\"opencli\"")) + XCTAssertTrue(message.contains("\"info\"")) + + // Verify it can be parsed as valid JSON + let jsonData = message.data(using: .utf8)! + let openCLI = try JSONDecoder().decode(OpenCLIv0_1.self, from: jsonData) + XCTAssertEqual(openCLI.opencli, "0.1") + } + } + + func testOpenCLIFlagVersusHelp() throws { + // Test that OpenCLI flag produces different output than regular help + let openCLIOutput: String + do { + _ = try TestCommand.parse(["--help-dump-opencli-v0.1"]) + XCTFail("Expected parsing to fail") + return + } catch { + openCLIOutput = TestCommand.fullMessage(for: error) + } + + let helpOutput: String + do { + _ = try TestCommand.parse(["--help"]) + XCTFail("Expected parsing to fail") + return + } catch { + helpOutput = TestCommand.fullMessage(for: error) + } + + // Verify outputs are different + XCTAssertNotEqual(openCLIOutput, helpOutput) + + // Verify OpenCLI is JSON + XCTAssertTrue(openCLIOutput.hasPrefix("{")) + XCTAssertTrue(openCLIOutput.hasSuffix("}")) + + // Verify help is text + XCTAssertTrue(helpOutput.contains("USAGE:")) + XCTAssertFalse(helpOutput.hasPrefix("{")) + } + + func testOpenCLIFlagWithInvalidArguments() throws { + // Test that OpenCLI flag works even when other arguments are invalid + do { + _ = try TestCommand.parse([ + "--help-dump-opencli-v0.1", "invalid", "extra", "args", + ]) + XCTFail("Expected parsing to fail with OpenCLI dump request") + } catch { + let message = TestCommand.fullMessage(for: error) + + // Should still produce OpenCLI JSON, not validation errors + XCTAssertTrue(message.contains("\"opencli\"")) + + let jsonData = message.data(using: .utf8)! + let openCLI = try JSONDecoder().decode(OpenCLIv0_1.self, from: jsonData) + XCTAssertEqual(openCLI.opencli, "0.1") + } + } +} diff --git a/Tests/ArgumentParserEndToEndTests/Snapshots/testADumpOpenCLI().json b/Tests/ArgumentParserEndToEndTests/Snapshots/testADumpOpenCLI().json new file mode 100644 index 00000000..251b1d3f --- /dev/null +++ b/Tests/ArgumentParserEndToEndTests/Snapshots/testADumpOpenCLI().json @@ -0,0 +1,92 @@ +{ + "arguments" : [ + { + "name" : "arg", + "required" : true + }, + { + "description" : "argument with help", + "name" : "arg-with-help", + "required" : true + }, + { + "description" : "argument with default value", + "name" : "arg-with-default-value", + "swiftArgumentParserDefaultValue" : "1" + } + ], + "info" : { + "title" : "a", + "version" : "1.0.0" + }, + "opencli" : "0.1", + "options" : [ + { + "arguments" : [ + { + "name" : "enumerated-option", + "required" : true + } + ], + "name" : "--enumerated-option", + "recursive" : false, + "required" : true + }, + { + "arguments" : [ + { + "name" : "enumerated-option-with-default-value", + "swiftArgumentParserDefaultValue" : "two" + } + ], + "name" : "--enumerated-option-with-default-value", + "recursive" : false, + "swiftArgumentParserDefaultValue" : "two" + }, + { + "arguments" : [ + { + "name" : "no-help-option", + "required" : true + } + ], + "name" : "--no-help-option", + "recursive" : false, + "required" : true + }, + { + "arguments" : [ + { + "description" : "int value option", + "name" : "int-option", + "required" : true + } + ], + "description" : "int value option", + "name" : "--int-option", + "recursive" : false, + "required" : true + }, + { + "arguments" : [ + { + "description" : "int value option with default value", + "name" : "int-option-with-default-value", + "swiftArgumentParserDefaultValue" : "0" + } + ], + "description" : "int value option with default value", + "name" : "--int-option-with-default-value", + "recursive" : false, + "swiftArgumentParserDefaultValue" : "0" + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] +} \ No newline at end of file diff --git a/Tests/ArgumentParserEndToEndTests/Snapshots/testBDumpOpenCLI().json b/Tests/ArgumentParserEndToEndTests/Snapshots/testBDumpOpenCLI().json new file mode 100644 index 00000000..b625186d --- /dev/null +++ b/Tests/ArgumentParserEndToEndTests/Snapshots/testBDumpOpenCLI().json @@ -0,0 +1,32 @@ +{ + "info" : { + "title" : "b", + "version" : "1.0.0" + }, + "opencli" : "0.1", + "options" : [ + { + "name" : "--verbose", + "recursive" : false + }, + { + "arguments" : [ + { + "name" : "name", + "required" : true + } + ], + "name" : "--name", + "recursive" : false, + "required" : true + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] +} \ No newline at end of file diff --git a/Tests/ArgumentParserEndToEndTests/Snapshots/testCDumpOpenCLI().json b/Tests/ArgumentParserEndToEndTests/Snapshots/testCDumpOpenCLI().json new file mode 100644 index 00000000..301531fc --- /dev/null +++ b/Tests/ArgumentParserEndToEndTests/Snapshots/testCDumpOpenCLI().json @@ -0,0 +1,76 @@ +{ + "info" : { + "title" : "c", + "version" : "1.0.0" + }, + "opencli" : "0.1", + "options" : [ + { + "arguments" : [ + { + "description" : "A color to select.", + "name" : "color", + "required" : true + } + ], + "description" : "A color to select.", + "name" : "--color", + "recursive" : false, + "required" : true + }, + { + "arguments" : [ + { + "description" : "Another color to select!", + "name" : "default-color", + "swiftArgumentParserDefaultValue" : "red" + } + ], + "description" : "Another color to select!", + "name" : "--default-color", + "recursive" : false, + "swiftArgumentParserDefaultValue" : "red" + }, + { + "arguments" : [ + { + "description" : "An optional color.", + "name" : "opt" + } + ], + "description" : "An optional color.", + "name" : "--opt", + "recursive" : false + }, + { + "arguments" : [ + { + "name" : "extra", + "required" : true + } + ], + "name" : "--extra", + "recursive" : false, + "required" : true + }, + { + "arguments" : [ + { + "name" : "discussion", + "required" : true + } + ], + "name" : "--discussion", + "recursive" : false, + "required" : true + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] +} \ No newline at end of file diff --git a/Tests/ArgumentParserEndToEndTests/Snapshots/testCommandWithOptionsDumpOpenCLI().json b/Tests/ArgumentParserEndToEndTests/Snapshots/testCommandWithOptionsDumpOpenCLI().json new file mode 100644 index 00000000..bc17b076 --- /dev/null +++ b/Tests/ArgumentParserEndToEndTests/Snapshots/testCommandWithOptionsDumpOpenCLI().json @@ -0,0 +1,63 @@ +{ + "arguments" : [ + { + "description" : "Required argument", + "name" : "required", + "required" : true + }, + { + "description" : "Optional argument", + "name" : "optional" + } + ], + "info" : { + "summary" : "Command with various option types", + "title" : "options", + "version" : "1.0.0" + }, + "opencli" : "0.1", + "options" : [ + { + "aliases" : [ + "-h" + ], + "description" : "Help flag", + "name" : "--help", + "recursive" : false + }, + { + "aliases" : [ + "--config" + ], + "arguments" : [ + { + "description" : "Configuration file", + "name" : "config" + } + ], + "description" : "Configuration file", + "name" : "-c", + "recursive" : false + }, + { + "arguments" : [ + { + "description" : "Repeating option", + "name" : "items" + } + ], + "description" : "Repeating option", + "name" : "--items", + "recursive" : false, + "swiftArgumentParserRepeating" : true + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] +} \ No newline at end of file diff --git a/Tests/ArgumentParserEndToEndTests/Snapshots/testMathAddDumpOpenCLI().json b/Tests/ArgumentParserEndToEndTests/Snapshots/testMathAddDumpOpenCLI().json new file mode 100644 index 00000000..047f97db --- /dev/null +++ b/Tests/ArgumentParserEndToEndTests/Snapshots/testMathAddDumpOpenCLI().json @@ -0,0 +1,306 @@ +{ + "arguments" : [ + { + "description" : "A group of integers to operate on.", + "name" : "values" + } + ], + "commands" : [ + { + "arguments" : [ + { + "description" : "A group of integers to operate on.", + "name" : "values" + } + ], + "description" : "Print the sum of the values.", + "name" : "add", + "options" : [ + { + "aliases" : [ + "-x" + ], + "description" : "Use hexadecimal notation for the result.", + "name" : "--hex-output", + "recursive" : false + }, + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] + }, + { + "aliases" : [ + "mul" + ], + "arguments" : [ + { + "description" : "A group of integers to operate on.", + "name" : "values" + } + ], + "description" : "Print the product of the values.", + "name" : "multiply", + "options" : [ + { + "aliases" : [ + "-x" + ], + "description" : "Use hexadecimal notation for the result.", + "name" : "--hex-output", + "recursive" : false + }, + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] + }, + { + "commands" : [ + { + "aliases" : [ + "avg" + ], + "arguments" : [ + { + "description" : "A group of floating-point values to operate on.", + "name" : "values" + } + ], + "description" : "Print the average of the values.", + "name" : "average", + "options" : [ + { + "arguments" : [ + { + "description" : "The kind of average to provide.", + "name" : "kind", + "swiftArgumentParserDefaultValue" : "mean" + } + ], + "description" : "The kind of average to provide.", + "name" : "--kind", + "recursive" : false, + "swiftArgumentParserDefaultValue" : "mean" + }, + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] + }, + { + "arguments" : [ + { + "description" : "A group of floating-point values to operate on.", + "name" : "values" + } + ], + "description" : "Print the standard deviation of the values.", + "name" : "stdev", + "options" : [ + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] + }, + { + "arguments" : [ + { + "name" : "one-of-four" + }, + { + "name" : "custom-arg" + }, + { + "name" : "custom-deprecated-arg" + }, + { + "description" : "A group of floating-point values to operate on.", + "name" : "values" + } + ], + "description" : "Print the quantiles of the values (TBD).", + "name" : "quantiles", + "options" : [ + { + "hidden" : true, + "name" : "--test-success-exit-code", + "recursive" : false + }, + { + "hidden" : true, + "name" : "--test-failure-exit-code", + "recursive" : false + }, + { + "hidden" : true, + "name" : "--test-validation-exit-code", + "recursive" : false + }, + { + "arguments" : [ + { + "name" : "test-custom-exit-code" + } + ], + "hidden" : true, + "name" : "--test-custom-exit-code", + "recursive" : false + }, + { + "arguments" : [ + { + "name" : "file" + } + ], + "name" : "--file", + "recursive" : false, + "swiftArgumentParserFile" : { + "extensions" : [ + "txt", + "md" + ] + } + }, + { + "arguments" : [ + { + "name" : "directory" + } + ], + "name" : "--directory", + "recursive" : false, + "swiftArgumentParserDirectory" : true + }, + { + "arguments" : [ + { + "name" : "shell" + } + ], + "name" : "--shell", + "recursive" : false + }, + { + "arguments" : [ + { + "name" : "custom" + } + ], + "name" : "--custom", + "recursive" : false + }, + { + "arguments" : [ + { + "name" : "custom-deprecated" + } + ], + "name" : "--custom-deprecated", + "recursive" : false + }, + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] + } + ], + "description" : "Calculate descriptive statistics.", + "name" : "stats", + "options" : [ + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] + } + ], + "info" : { + "summary" : "A utility for performing maths.", + "title" : "math", + "version" : "1.0.0" + }, + "opencli" : "0.1", + "options" : [ + { + "aliases" : [ + "-x" + ], + "description" : "Use hexadecimal notation for the result.", + "name" : "--hex-output", + "recursive" : false + }, + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] +} diff --git a/Tests/ArgumentParserEndToEndTests/Snapshots/testMathDumpOpenCLI().json b/Tests/ArgumentParserEndToEndTests/Snapshots/testMathDumpOpenCLI().json new file mode 100644 index 00000000..a201b695 --- /dev/null +++ b/Tests/ArgumentParserEndToEndTests/Snapshots/testMathDumpOpenCLI().json @@ -0,0 +1,292 @@ +{ + "commands" : [ + { + "arguments" : [ + { + "description" : "A group of integers to operate on.", + "name" : "values" + } + ], + "description" : "Print the sum of the values.", + "name" : "add", + "options" : [ + { + "aliases" : [ + "-x" + ], + "description" : "Use hexadecimal notation for the result.", + "name" : "--hex-output", + "recursive" : false + }, + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] + }, + { + "aliases" : [ + "mul" + ], + "arguments" : [ + { + "description" : "A group of integers to operate on.", + "name" : "values" + } + ], + "description" : "Print the product of the values.", + "name" : "multiply", + "options" : [ + { + "aliases" : [ + "-x" + ], + "description" : "Use hexadecimal notation for the result.", + "name" : "--hex-output", + "recursive" : false + }, + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] + }, + { + "commands" : [ + { + "aliases" : [ + "avg" + ], + "arguments" : [ + { + "description" : "A group of floating-point values to operate on.", + "name" : "values" + } + ], + "description" : "Print the average of the values.", + "name" : "average", + "options" : [ + { + "arguments" : [ + { + "description" : "The kind of average to provide.", + "name" : "kind", + "swiftArgumentParserDefaultValue" : "mean" + } + ], + "description" : "The kind of average to provide.", + "name" : "--kind", + "recursive" : false, + "swiftArgumentParserDefaultValue" : "mean" + }, + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] + }, + { + "arguments" : [ + { + "description" : "A group of floating-point values to operate on.", + "name" : "values" + } + ], + "description" : "Print the standard deviation of the values.", + "name" : "stdev", + "options" : [ + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] + }, + { + "arguments" : [ + { + "name" : "one-of-four" + }, + { + "name" : "custom-arg" + }, + { + "name" : "custom-deprecated-arg" + }, + { + "description" : "A group of floating-point values to operate on.", + "name" : "values" + } + ], + "description" : "Print the quantiles of the values (TBD).", + "name" : "quantiles", + "options" : [ + { + "hidden" : true, + "name" : "--test-success-exit-code", + "recursive" : false + }, + { + "hidden" : true, + "name" : "--test-failure-exit-code", + "recursive" : false + }, + { + "hidden" : true, + "name" : "--test-validation-exit-code", + "recursive" : false + }, + { + "arguments" : [ + { + "name" : "test-custom-exit-code" + } + ], + "hidden" : true, + "name" : "--test-custom-exit-code", + "recursive" : false + }, + { + "arguments" : [ + { + "name" : "file" + } + ], + "name" : "--file", + "recursive" : false, + "swiftArgumentParserFile" : { + "extensions" : [ + "txt", + "md" + ] + } + }, + { + "arguments" : [ + { + "name" : "directory" + } + ], + "name" : "--directory", + "recursive" : false, + "swiftArgumentParserDirectory" : true + }, + { + "arguments" : [ + { + "name" : "shell" + } + ], + "name" : "--shell", + "recursive" : false + }, + { + "arguments" : [ + { + "name" : "custom" + } + ], + "name" : "--custom", + "recursive" : false + }, + { + "arguments" : [ + { + "name" : "custom-deprecated" + } + ], + "name" : "--custom-deprecated", + "recursive" : false + }, + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] + } + ], + "description" : "Calculate descriptive statistics.", + "name" : "stats", + "options" : [ + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] + } + ], + "info" : { + "summary" : "A utility for performing maths.", + "title" : "math", + "version" : "1.0.0" + }, + "opencli" : "0.1", + "options" : [ + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] +} diff --git a/Tests/ArgumentParserEndToEndTests/Snapshots/testMathMultiplyDumpOpenCLI().json b/Tests/ArgumentParserEndToEndTests/Snapshots/testMathMultiplyDumpOpenCLI().json new file mode 100644 index 00000000..047f97db --- /dev/null +++ b/Tests/ArgumentParserEndToEndTests/Snapshots/testMathMultiplyDumpOpenCLI().json @@ -0,0 +1,306 @@ +{ + "arguments" : [ + { + "description" : "A group of integers to operate on.", + "name" : "values" + } + ], + "commands" : [ + { + "arguments" : [ + { + "description" : "A group of integers to operate on.", + "name" : "values" + } + ], + "description" : "Print the sum of the values.", + "name" : "add", + "options" : [ + { + "aliases" : [ + "-x" + ], + "description" : "Use hexadecimal notation for the result.", + "name" : "--hex-output", + "recursive" : false + }, + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] + }, + { + "aliases" : [ + "mul" + ], + "arguments" : [ + { + "description" : "A group of integers to operate on.", + "name" : "values" + } + ], + "description" : "Print the product of the values.", + "name" : "multiply", + "options" : [ + { + "aliases" : [ + "-x" + ], + "description" : "Use hexadecimal notation for the result.", + "name" : "--hex-output", + "recursive" : false + }, + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] + }, + { + "commands" : [ + { + "aliases" : [ + "avg" + ], + "arguments" : [ + { + "description" : "A group of floating-point values to operate on.", + "name" : "values" + } + ], + "description" : "Print the average of the values.", + "name" : "average", + "options" : [ + { + "arguments" : [ + { + "description" : "The kind of average to provide.", + "name" : "kind", + "swiftArgumentParserDefaultValue" : "mean" + } + ], + "description" : "The kind of average to provide.", + "name" : "--kind", + "recursive" : false, + "swiftArgumentParserDefaultValue" : "mean" + }, + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] + }, + { + "arguments" : [ + { + "description" : "A group of floating-point values to operate on.", + "name" : "values" + } + ], + "description" : "Print the standard deviation of the values.", + "name" : "stdev", + "options" : [ + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] + }, + { + "arguments" : [ + { + "name" : "one-of-four" + }, + { + "name" : "custom-arg" + }, + { + "name" : "custom-deprecated-arg" + }, + { + "description" : "A group of floating-point values to operate on.", + "name" : "values" + } + ], + "description" : "Print the quantiles of the values (TBD).", + "name" : "quantiles", + "options" : [ + { + "hidden" : true, + "name" : "--test-success-exit-code", + "recursive" : false + }, + { + "hidden" : true, + "name" : "--test-failure-exit-code", + "recursive" : false + }, + { + "hidden" : true, + "name" : "--test-validation-exit-code", + "recursive" : false + }, + { + "arguments" : [ + { + "name" : "test-custom-exit-code" + } + ], + "hidden" : true, + "name" : "--test-custom-exit-code", + "recursive" : false + }, + { + "arguments" : [ + { + "name" : "file" + } + ], + "name" : "--file", + "recursive" : false, + "swiftArgumentParserFile" : { + "extensions" : [ + "txt", + "md" + ] + } + }, + { + "arguments" : [ + { + "name" : "directory" + } + ], + "name" : "--directory", + "recursive" : false, + "swiftArgumentParserDirectory" : true + }, + { + "arguments" : [ + { + "name" : "shell" + } + ], + "name" : "--shell", + "recursive" : false + }, + { + "arguments" : [ + { + "name" : "custom" + } + ], + "name" : "--custom", + "recursive" : false + }, + { + "arguments" : [ + { + "name" : "custom-deprecated" + } + ], + "name" : "--custom-deprecated", + "recursive" : false + }, + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] + } + ], + "description" : "Calculate descriptive statistics.", + "name" : "stats", + "options" : [ + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] + } + ], + "info" : { + "summary" : "A utility for performing maths.", + "title" : "math", + "version" : "1.0.0" + }, + "opencli" : "0.1", + "options" : [ + { + "aliases" : [ + "-x" + ], + "description" : "Use hexadecimal notation for the result.", + "name" : "--hex-output", + "recursive" : false + }, + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] +} diff --git a/Tests/ArgumentParserEndToEndTests/Snapshots/testMathStatsDumpOpenCLI().json b/Tests/ArgumentParserEndToEndTests/Snapshots/testMathStatsDumpOpenCLI().json new file mode 100644 index 00000000..a201b695 --- /dev/null +++ b/Tests/ArgumentParserEndToEndTests/Snapshots/testMathStatsDumpOpenCLI().json @@ -0,0 +1,292 @@ +{ + "commands" : [ + { + "arguments" : [ + { + "description" : "A group of integers to operate on.", + "name" : "values" + } + ], + "description" : "Print the sum of the values.", + "name" : "add", + "options" : [ + { + "aliases" : [ + "-x" + ], + "description" : "Use hexadecimal notation for the result.", + "name" : "--hex-output", + "recursive" : false + }, + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] + }, + { + "aliases" : [ + "mul" + ], + "arguments" : [ + { + "description" : "A group of integers to operate on.", + "name" : "values" + } + ], + "description" : "Print the product of the values.", + "name" : "multiply", + "options" : [ + { + "aliases" : [ + "-x" + ], + "description" : "Use hexadecimal notation for the result.", + "name" : "--hex-output", + "recursive" : false + }, + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] + }, + { + "commands" : [ + { + "aliases" : [ + "avg" + ], + "arguments" : [ + { + "description" : "A group of floating-point values to operate on.", + "name" : "values" + } + ], + "description" : "Print the average of the values.", + "name" : "average", + "options" : [ + { + "arguments" : [ + { + "description" : "The kind of average to provide.", + "name" : "kind", + "swiftArgumentParserDefaultValue" : "mean" + } + ], + "description" : "The kind of average to provide.", + "name" : "--kind", + "recursive" : false, + "swiftArgumentParserDefaultValue" : "mean" + }, + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] + }, + { + "arguments" : [ + { + "description" : "A group of floating-point values to operate on.", + "name" : "values" + } + ], + "description" : "Print the standard deviation of the values.", + "name" : "stdev", + "options" : [ + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] + }, + { + "arguments" : [ + { + "name" : "one-of-four" + }, + { + "name" : "custom-arg" + }, + { + "name" : "custom-deprecated-arg" + }, + { + "description" : "A group of floating-point values to operate on.", + "name" : "values" + } + ], + "description" : "Print the quantiles of the values (TBD).", + "name" : "quantiles", + "options" : [ + { + "hidden" : true, + "name" : "--test-success-exit-code", + "recursive" : false + }, + { + "hidden" : true, + "name" : "--test-failure-exit-code", + "recursive" : false + }, + { + "hidden" : true, + "name" : "--test-validation-exit-code", + "recursive" : false + }, + { + "arguments" : [ + { + "name" : "test-custom-exit-code" + } + ], + "hidden" : true, + "name" : "--test-custom-exit-code", + "recursive" : false + }, + { + "arguments" : [ + { + "name" : "file" + } + ], + "name" : "--file", + "recursive" : false, + "swiftArgumentParserFile" : { + "extensions" : [ + "txt", + "md" + ] + } + }, + { + "arguments" : [ + { + "name" : "directory" + } + ], + "name" : "--directory", + "recursive" : false, + "swiftArgumentParserDirectory" : true + }, + { + "arguments" : [ + { + "name" : "shell" + } + ], + "name" : "--shell", + "recursive" : false + }, + { + "arguments" : [ + { + "name" : "custom" + } + ], + "name" : "--custom", + "recursive" : false + }, + { + "arguments" : [ + { + "name" : "custom-deprecated" + } + ], + "name" : "--custom-deprecated", + "recursive" : false + }, + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] + } + ], + "description" : "Calculate descriptive statistics.", + "name" : "stats", + "options" : [ + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] + } + ], + "info" : { + "summary" : "A utility for performing maths.", + "title" : "math", + "version" : "1.0.0" + }, + "opencli" : "0.1", + "options" : [ + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] +} diff --git a/Tests/ArgumentParserEndToEndTests/Snapshots/testNestedCommandDumpOpenCLI().json b/Tests/ArgumentParserEndToEndTests/Snapshots/testNestedCommandDumpOpenCLI().json new file mode 100644 index 00000000..e5a650ed --- /dev/null +++ b/Tests/ArgumentParserEndToEndTests/Snapshots/testNestedCommandDumpOpenCLI().json @@ -0,0 +1,52 @@ +{ + "commands" : [ + { + "description" : "A subcommand", + "name" : "sub", + "options" : [ + { + "arguments" : [ + { + "description" : "Sub option", + "name" : "value", + "swiftArgumentParserDefaultValue" : "42" + } + ], + "description" : "Sub option", + "name" : "--value", + "recursive" : false, + "swiftArgumentParserDefaultValue" : "42" + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] + } + ], + "info" : { + "summary" : "A parent command with subcommands", + "title" : "parent", + "version" : "1.0.0" + }, + "opencli" : "0.1", + "options" : [ + { + "description" : "Global flag", + "name" : "--global", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] +} \ No newline at end of file diff --git a/Tests/ArgumentParserEndToEndTests/Snapshots/testSimpleCommandDumpOpenCLI().json b/Tests/ArgumentParserEndToEndTests/Snapshots/testSimpleCommandDumpOpenCLI().json new file mode 100644 index 00000000..05f902a8 --- /dev/null +++ b/Tests/ArgumentParserEndToEndTests/Snapshots/testSimpleCommandDumpOpenCLI().json @@ -0,0 +1,52 @@ +{ + "arguments" : [ + { + "description" : "Output file path", + "name" : "output", + "required" : true + } + ], + "info" : { + "summary" : "A simple command for testing", + "title" : "simple", + "version" : "1.0.0" + }, + "opencli" : "0.1", + "options" : [ + { + "aliases" : [ + "--verbose" + ], + "description" : "Show verbose output", + "name" : "-v", + "recursive" : false + }, + { + "aliases" : [ + "--input" + ], + "arguments" : [ + { + "description" : "Input file path", + "name" : "input" + } + ], + "description" : "Input file path", + "name" : "-i", + "recursive" : false + }, + { + "description" : "Show the version.", + "name" : "--version", + "recursive" : false + }, + { + "aliases" : [ + "--help" + ], + "description" : "Show help information.", + "name" : "-h", + "recursive" : false + } + ] +} \ No newline at end of file diff --git a/Tests/ArgumentParserUnitTests/OpenCLITests.swift b/Tests/ArgumentParserUnitTests/OpenCLITests.swift new file mode 100644 index 00000000..19ab58b8 --- /dev/null +++ b/Tests/ArgumentParserUnitTests/OpenCLITests.swift @@ -0,0 +1,256 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Argument Parser open source project +// +// Copyright (c) 2020 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +import Foundation +import XCTest + +@testable import ArgumentParserOpenCLI + +final class OpenCLITests: XCTestCase { + + func testDecodeExampleJSON() throws { + let jsonString = """ + { + "$schema": "https://opencli.org/draft.json", + "opencli": "0.1", + "info": { + "title": "dotnet", + "version": "9.0.1", + "description": "The .NET CLI", + "license": { + "name": "MIT License", + "identifier": "MIT" + } + }, + "options": [ + { + "name": "--help", + "aliases": [ "-h" ], + "description": "Display help." + }, + { + "name": "--info", + "description": "Display .NET information." + }, + { + "name": "--list-sdks", + "description": "Display the installed SDKs." + }, + { + "name": "--list-runtimes", + "description": "Display the installed runtimes." + } + ], + "commands": [ + { + "name": "build", + "arguments": [ + { + "name": "PROJECT | SOLUTION", + "description": "The project or solution file to operate on. If a file is not specified, the command will search the current directory for one." + } + ], + "options": [ + { + "name": "--configuration", + "aliases": [ "-c" ], + "description": "The configuration to use for building the project. The default for most projects is 'Debug'.", + "arguments": [ + { + "name": "CONFIGURATION", + "required": true, + "arity": { + "minimum": 1, + "maximum": 1 + } + } + ] + } + ] + } + ] + } + """ + + let jsonData = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + + let openCLI = try decoder.decode(OpenCLIv0_1.self, from: jsonData) + + // Verify root properties + XCTAssertEqual(openCLI.opencli, "0.1") + + // Verify info + XCTAssertEqual(openCLI.info.title, "dotnet") + XCTAssertEqual(openCLI.info.version, "9.0.1") + XCTAssertEqual(openCLI.info.description, "The .NET CLI") + XCTAssertEqual(openCLI.info.license?.name, "MIT License") + XCTAssertEqual(openCLI.info.license?.identifier, "MIT") + + // Verify options + XCTAssertEqual(openCLI.options?.count, 4) + + let helpOption = openCLI.options?[0] + XCTAssertEqual(helpOption?.name, "--help") + XCTAssertEqual(helpOption?.aliases, ["-h"]) + XCTAssertEqual(helpOption?.description, "Display help.") + + let infoOption = openCLI.options?[1] + XCTAssertEqual(infoOption?.name, "--info") + XCTAssertEqual(infoOption?.description, "Display .NET information.") + + // Verify commands + XCTAssertEqual(openCLI.commands?.count, 1) + + let buildCommand = openCLI.commands?[0] + XCTAssertEqual(buildCommand?.name, "build") + + // Verify command arguments + XCTAssertEqual(buildCommand?.arguments?.count, 1) + let projectArg = buildCommand?.arguments?[0] + XCTAssertEqual(projectArg?.name, "PROJECT | SOLUTION") + XCTAssertEqual( + projectArg?.description, + "The project or solution file to operate on. If a file is not specified, the command will search the current directory for one." + ) + + // Verify command options + XCTAssertEqual(buildCommand?.options?.count, 1) + let configOption = buildCommand?.options?[0] + XCTAssertEqual(configOption?.name, "--configuration") + XCTAssertEqual(configOption?.aliases, ["-c"]) + XCTAssertEqual( + configOption?.description, + "The configuration to use for building the project. The default for most projects is 'Debug'." + ) + + // Verify option arguments with arity + XCTAssertEqual(configOption?.arguments?.count, 1) + let configArg = configOption?.arguments?[0] + XCTAssertEqual(configArg?.name, "CONFIGURATION") + XCTAssertEqual(configArg?.required, true) + XCTAssertEqual(configArg?.arity?.minimum, 1) + XCTAssertEqual(configArg?.arity?.maximum, 1) + } + + func testEncodeToJSON() throws { + let license = OpenCLIv0_1.License(name: "MIT License", identifier: "MIT") + let info = OpenCLIv0_1.CliInfo( + title: "test-cli", + version: "1.0.0", + summary: "A test CLI", + description: "Test CLI description", + license: license + ) + + let helpOption = OpenCLIv0_1.Option( + name: "--help", + aliases: ["-h"], + description: "Show help" + ) + + let arity = OpenCLIv0_1.Arity(minimum: 1, maximum: 1) + let configArg = OpenCLIv0_1.Argument( + name: "CONFIG", + required: true, + arity: arity + ) + + let configOption = OpenCLIv0_1.Option( + name: "--config", + aliases: ["-c"], + arguments: [configArg], + description: "Configuration option" + ) + + let buildCommand = OpenCLIv0_1.Command( + name: "build", + options: [configOption], + description: "Build the project" + ) + + let openCLI = OpenCLIv0_1( + opencli: "0.1", + info: info, + options: [helpOption], + commands: [buildCommand] + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + + let jsonData = try encoder.encode(openCLI) + let jsonString = String(data: jsonData, encoding: .utf8)! + + // Verify we can encode and the result contains expected keys + XCTAssertTrue(jsonString.contains("\"opencli\" : \"0.1\"")) + XCTAssertTrue(jsonString.contains("\"title\" : \"test-cli\"")) + XCTAssertTrue(jsonString.contains("\"name\" : \"build\"")) + XCTAssertTrue(jsonString.contains("\"--help\"")) + + // Verify round-trip: decode the encoded JSON + let decoder = JSONDecoder() + let decodedOpenCLI = try decoder.decode(OpenCLIv0_1.self, from: jsonData) + + XCTAssertEqual(decodedOpenCLI.opencli, openCLI.opencli) + XCTAssertEqual(decodedOpenCLI.info.title, openCLI.info.title) + XCTAssertEqual( + decodedOpenCLI.commands?.first?.name, openCLI.commands?.first?.name) + } + + func testOpenCLIEquatable() throws { + let license1 = OpenCLIv0_1.License(name: "MIT License", identifier: "MIT") + let license2 = OpenCLIv0_1.License(name: "MIT License", identifier: "MIT") + let license3 = OpenCLIv0_1.License( + name: "Apache License", identifier: "Apache-2.0") + + let info1 = OpenCLIv0_1.CliInfo( + title: "test", version: "1.0.0", license: license1) + let info2 = OpenCLIv0_1.CliInfo( + title: "test", version: "1.0.0", license: license2) + let info3 = OpenCLIv0_1.CliInfo( + title: "test", version: "2.0.0", license: license1) + + let openCLI1 = OpenCLIv0_1(opencli: "0.1", info: info1) + let openCLI2 = OpenCLIv0_1(opencli: "0.1", info: info2) + let openCLI3 = OpenCLIv0_1(opencli: "0.1", info: info3) + + // Test equality + XCTAssertEqual(license1, license2) + XCTAssertNotEqual(license1, license3) + XCTAssertEqual(info1, info2) + XCTAssertNotEqual(info1, info3) + XCTAssertEqual(openCLI1, openCLI2) + XCTAssertNotEqual(openCLI1, openCLI3) + + // Test AnyCodable equality + let metadata1 = OpenCLIv0_1.Metadata( + name: "key", value: OpenCLIv0_1.AnyCodable("value")) + let metadata2 = OpenCLIv0_1.Metadata( + name: "key", value: OpenCLIv0_1.AnyCodable("value")) + let metadata3 = OpenCLIv0_1.Metadata( + name: "key", value: OpenCLIv0_1.AnyCodable("different")) + + XCTAssertEqual(metadata1, metadata2) + XCTAssertNotEqual(metadata1, metadata3) + + // Test complex structures + let option1 = OpenCLIv0_1.Option( + name: "--verbose", aliases: ["-v"], description: "Verbose output") + let option2 = OpenCLIv0_1.Option( + name: "--verbose", aliases: ["-v"], description: "Verbose output") + let option3 = OpenCLIv0_1.Option( + name: "--quiet", aliases: ["-q"], description: "Quiet output") + + XCTAssertEqual(option1, option2) + XCTAssertNotEqual(option1, option3) + } +}