From 478c2df6ab904da9fe8bbc21210e2dd0c50e73c9 Mon Sep 17 00:00:00 2001 From: Nate Cook Date: Fri, 27 Jan 2023 10:45:06 -0600 Subject: [PATCH 1/8] Improve failure message for backdeployed AsyncParsableCommand (#547) When an executable with asynchronous commands is backdeployed, the compiler chooses the synchronous `main()` unless a minimum availability target is provided for the root command type. This changes the error message provided when the incorrect `main()` function is called to direct the tool's author to a correct solution. --- .../Parsable Types/ParsableCommand.swift | 60 ++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift b/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift index a69f268d2..288ea7f6e 100644 --- a/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift +++ b/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift @@ -127,7 +127,13 @@ extension ParsableCommand { #if DEBUG if #available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *) { - checkAsyncHierarchy(self, root: "\(self)") + if let asyncCommand = firstAsyncSubcommand(self) { + if Self() is AsyncParsableCommand { + failAsyncPlatform(rootCommand: self) + } else { + failAsyncHierarchy(rootCommand: self, subCommand: asyncCommand) + } + } } #endif @@ -194,5 +200,57 @@ extension ParsableCommand { """.wrapped(to: 70)) } } + + @available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *) + internal static func firstAsyncSubcommand(_ command: ParsableCommand.Type) -> AsyncParsableCommand.Type? { + for sub in command.configuration.subcommands { + if let asyncCommand = sub as? AsyncParsableCommand.Type, + sub.configuration.subcommands.isEmpty + { + return asyncCommand + } + + if let asyncCommand = firstAsyncSubcommand(sub) { + return asyncCommand + } + } + + return nil + } #endif } + +// MARK: Async Configuration Errors + +func failAsyncHierarchy( + rootCommand: ParsableCommand.Type, subCommand: ParsableCommand.Type +) -> Never { + fatalError(""" + + -------------------------------------------------------------------- + Asynchronous subcommand of a synchronous root. + + The asynchronous command `\(subCommand)` is declared as a subcommand of the synchronous root command `\(rootCommand)`. + + With this configuration, your asynchronous `run()` method will not be called. To fix this issue, change `\(rootCommand)`'s `ParsableCommand` conformance to `AsyncParsableCommand`. + -------------------------------------------------------------------- + + """.wrapped(to: 70)) +} + +func failAsyncPlatform(rootCommand: ParsableCommand.Type) -> Never { + fatalError(""" + + -------------------------------------------------------------------- + Asynchronous root command needs availability annotation. + + The asynchronous root command `\(rootCommand)` needs an availability annotation in order to be executed asynchronously. To fix this issue, add the following availability attribute to your `\(rootCommand)` declaration or set the minimum platform in your "Package.swift" file. + + """.wrapped(to: 70) + + """ + + @available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *) + -------------------------------------------------------------------- + + """) +} From a9b9644153cb27806b16aff1594a84192dcd7e4c Mon Sep 17 00:00:00 2001 From: Nate Cook Date: Thu, 2 Feb 2023 08:33:51 -0600 Subject: [PATCH 2/8] Stop removing underscores from CodingKey names in InputKey (#548) When a property wrapper is applied to a property, the property's storage is given a name with a prefixed underscore. That is, for a property named `x`, the actual storage is named `_x`. That prefixed storage is what is visible through reflection, so when building an ArgumentSet from a command type's Mirror, we need to remove the leading underscore. This is done when creating an InputKey for each property. However, InputKeys are also created from CodingKeys during decoding of a ParsableCommand. These CodingKeys _do not_ have the leading underscore that is visible, so any underscores that appear are actually from the declaration of the property with an underscored name. Removing leading underscores from CodingKey names results in a mismatch when trying to find the decoded value. This change simplifies the InputKey type to use an array path instead of an indirect enum and removes the leading underscore dropping when creating an InputKey from a CodingKey. rdar://104928743 --- .../BashCompletionsGenerator.swift | 4 +- .../Parsable Properties/Flag.swift | 8 +- .../NameSpecification.swift | 2 +- .../Parsable Properties/OptionGroup.swift | 2 +- .../Parsable Types/ParsableArguments.swift | 2 +- .../ParsableArgumentsValidation.swift | 12 +- .../Parsable Types/ParsableCommand.swift | 2 +- .../Parsing/ArgumentDefinition.swift | 2 +- .../ArgumentParser/Parsing/ArgumentSet.swift | 2 +- .../Parsing/CommandParser.swift | 4 +- Sources/ArgumentParser/Parsing/InputKey.swift | 128 ++++-------------- .../Usage/DumpHelpGenerator.swift | 2 +- .../ArgumentParser/Usage/HelpGenerator.swift | 12 +- .../ArgumentParser/Usage/MessageInfo.swift | 2 +- .../ArgumentParser/Usage/UsageGenerator.swift | 2 +- .../DefaultsEndToEndTests.swift | 30 ++++ .../OptionGroupEndToEndTests.swift | 50 ++++--- .../HelpGenerationTests.swift | 2 +- .../NameSpecificationTests.swift | 6 +- .../ParsableArgumentsValidationTests.swift | 24 ++-- .../UsageGenerationTests.swift | 2 +- 21 files changed, 135 insertions(+), 165 deletions(-) diff --git a/Sources/ArgumentParser/Completions/BashCompletionsGenerator.swift b/Sources/ArgumentParser/Completions/BashCompletionsGenerator.swift index 753ebcf51..1fea1b73e 100644 --- a/Sources/ArgumentParser/Completions/BashCompletionsGenerator.swift +++ b/Sources/ArgumentParser/Completions/BashCompletionsGenerator.swift @@ -132,7 +132,7 @@ struct BashCompletionsGenerator { /// /// These consist of completions that are defined as `.list` or `.custom`. fileprivate static func generateArgumentCompletions(_ commands: [ParsableCommand.Type]) -> [String] { - ArgumentSet(commands.last!, visibility: .default, parent: .root) + ArgumentSet(commands.last!, visibility: .default, parent: nil) .compactMap { arg -> String? in guard arg.isPositional else { return nil } @@ -159,7 +159,7 @@ struct BashCompletionsGenerator { /// Returns the case-matching statements for supplying completions after an option or flag. fileprivate static func generateOptionHandlers(_ commands: [ParsableCommand.Type]) -> String { - ArgumentSet(commands.last!, visibility: .default, parent: .root) + ArgumentSet(commands.last!, visibility: .default, parent: nil) .compactMap { arg -> String? in let words = arg.bashCompletionWords() if words.isEmpty { return nil } diff --git a/Sources/ArgumentParser/Parsable Properties/Flag.swift b/Sources/ArgumentParser/Parsable Properties/Flag.swift index 334df8874..74a97f795 100644 --- a/Sources/ArgumentParser/Parsable Properties/Flag.swift +++ b/Sources/ArgumentParser/Parsable Properties/Flag.swift @@ -396,7 +396,7 @@ extension Flag where Value: EnumerableFlag { // flag, the default value to show to the user is the `--value-name` // flag that a user would provide on the command line, not a Swift value. let defaultValueFlag = initial.flatMap { value -> String? in - let defaultKey = InputKey(name: String(describing: value), parent: .key(key)) + let defaultKey = InputKey(name: String(describing: value), parent: key) let defaultNames = Value.name(for: value).makeNames(defaultKey) return defaultNames.first?.synopsisString } @@ -405,7 +405,7 @@ extension Flag where Value: EnumerableFlag { let hasCustomCaseHelp = caseHelps.contains(where: { $0 != nil }) let args = Value.allCases.enumerated().map { (i, value) -> ArgumentDefinition in - let caseKey = InputKey(name: String(describing: value), parent: .key(key)) + let caseKey = InputKey(name: String(describing: value), parent: key) let name = Value.name(for: value) let helpForCase = caseHelps[i] ?? help @@ -519,7 +519,7 @@ extension Flag { let hasCustomCaseHelp = caseHelps.contains(where: { $0 != nil }) let args = Element.allCases.enumerated().map { (i, value) -> ArgumentDefinition in - let caseKey = InputKey(name: String(describing: value), parent: .key(parentKey)) + let caseKey = InputKey(name: String(describing: value), parent: parentKey) let name = Element.name(for: value) let helpForCase = hasCustomCaseHelp ? (caseHelps[i] ?? help) : help @@ -552,7 +552,7 @@ extension Flag { let hasCustomCaseHelp = caseHelps.contains(where: { $0 != nil }) let args = Element.allCases.enumerated().map { (i, value) -> ArgumentDefinition in - let caseKey = InputKey(name: String(describing: value), parent: .key(parentKey)) + let caseKey = InputKey(name: String(describing: value), parent: parentKey) let name = Element.name(for: value) let helpForCase = hasCustomCaseHelp ? (caseHelps[i] ?? help) : help let help = ArgumentDefinition.Help( diff --git a/Sources/ArgumentParser/Parsable Properties/NameSpecification.swift b/Sources/ArgumentParser/Parsable Properties/NameSpecification.swift index bfdd48c4a..a71d9df6a 100644 --- a/Sources/ArgumentParser/Parsable Properties/NameSpecification.swift +++ b/Sources/ArgumentParser/Parsable Properties/NameSpecification.swift @@ -167,7 +167,7 @@ extension FlagInversion { case .short, .customShort: return includingShort ? element.name(for: key) : nil case .long: - let modifiedKey = key.with(newName: key.name.addingIntercappedPrefix(prefix)) + let modifiedKey = InputKey(name: key.name.addingIntercappedPrefix(prefix), parent: key) return element.name(for: modifiedKey) case .customLong(let name, let withSingleDash): let modifiedName = name.addingPrefixWithAutodetectedStyle(prefix) diff --git a/Sources/ArgumentParser/Parsable Properties/OptionGroup.swift b/Sources/ArgumentParser/Parsable Properties/OptionGroup.swift index b74865b62..13192f8ed 100644 --- a/Sources/ArgumentParser/Parsable Properties/OptionGroup.swift +++ b/Sources/ArgumentParser/Parsable Properties/OptionGroup.swift @@ -78,7 +78,7 @@ public struct OptionGroup: Decodable, ParsedWrapper { visibility: ArgumentVisibility = .default ) { self.init(_parsedValue: .init { parentKey in - var args = ArgumentSet(Value.self, visibility: .private, parent: .key(parentKey)) + var args = ArgumentSet(Value.self, visibility: .private, parent: parentKey) args.content.withEach { $0.help.parentTitle = title } diff --git a/Sources/ArgumentParser/Parsable Types/ParsableArguments.swift b/Sources/ArgumentParser/Parsable Types/ParsableArguments.swift index b2e572a6e..d85283c37 100644 --- a/Sources/ArgumentParser/Parsable Types/ParsableArguments.swift +++ b/Sources/ArgumentParser/Parsable Types/ParsableArguments.swift @@ -248,7 +248,7 @@ extension ArgumentSetProvider { } extension ArgumentSet { - init(_ type: ParsableArguments.Type, visibility: ArgumentVisibility, parent: InputKey.Parent) { + init(_ type: ParsableArguments.Type, visibility: ArgumentVisibility, parent: InputKey?) { #if DEBUG do { try type._validate(parent: parent) diff --git a/Sources/ArgumentParser/Parsable Types/ParsableArgumentsValidation.swift b/Sources/ArgumentParser/Parsable Types/ParsableArgumentsValidation.swift index f633d91f7..c03e9d3af 100644 --- a/Sources/ArgumentParser/Parsable Types/ParsableArgumentsValidation.swift +++ b/Sources/ArgumentParser/Parsable Types/ParsableArgumentsValidation.swift @@ -10,7 +10,7 @@ //===----------------------------------------------------------------------===// fileprivate protocol ParsableArgumentsValidator { - static func validate(_ type: ParsableArguments.Type, parent: InputKey.Parent) -> ParsableArgumentsValidatorError? + static func validate(_ type: ParsableArguments.Type, parent: InputKey?) -> ParsableArgumentsValidatorError? } enum ValidatorErrorKind { @@ -37,7 +37,7 @@ struct ParsableArgumentsValidationError: Error, CustomStringConvertible { } extension ParsableArguments { - static func _validate(parent: InputKey.Parent) throws { + static func _validate(parent: InputKey?) throws { let validators: [ParsableArgumentsValidator.Type] = [ PositionalArgumentsValidator.self, ParsableArgumentsCodingKeyValidator.self, @@ -80,7 +80,7 @@ struct PositionalArgumentsValidator: ParsableArgumentsValidator { var kind: ValidatorErrorKind { .failure } } - static func validate(_ type: ParsableArguments.Type, parent: InputKey.Parent) -> ParsableArgumentsValidatorError? { + static func validate(_ type: ParsableArguments.Type, parent: InputKey?) -> ParsableArgumentsValidatorError? { let sets: [ArgumentSet] = Mirror(reflecting: type.init()) .children .compactMap { child in @@ -190,7 +190,7 @@ struct ParsableArgumentsCodingKeyValidator: ParsableArgumentsValidator { } } - static func validate(_ type: ParsableArguments.Type, parent: InputKey.Parent) -> ParsableArgumentsValidatorError? { + static func validate(_ type: ParsableArguments.Type, parent: InputKey?) -> ParsableArgumentsValidatorError? { let argumentKeys: [InputKey] = Mirror(reflecting: type.init()) .children .compactMap { child in @@ -235,7 +235,7 @@ struct ParsableArgumentsUniqueNamesValidator: ParsableArgumentsValidator { var kind: ValidatorErrorKind { .failure } } - static func validate(_ type: ParsableArguments.Type, parent: InputKey.Parent) -> ParsableArgumentsValidatorError? { + static func validate(_ type: ParsableArguments.Type, parent: InputKey?) -> ParsableArgumentsValidatorError? { let argSets: [ArgumentSet] = Mirror(reflecting: type.init()) .children .compactMap { child in @@ -283,7 +283,7 @@ struct NonsenseFlagsValidator: ParsableArgumentsValidator { var kind: ValidatorErrorKind { .warning } } - static func validate(_ type: ParsableArguments.Type, parent: InputKey.Parent) -> ParsableArgumentsValidatorError? { + static func validate(_ type: ParsableArguments.Type, parent: InputKey?) -> ParsableArgumentsValidatorError? { let argSets: [ArgumentSet] = Mirror(reflecting: type.init()) .children .compactMap { child in diff --git a/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift b/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift index 288ea7f6e..af835a007 100644 --- a/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift +++ b/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift @@ -166,7 +166,7 @@ extension ParsableCommand { /// `true` if this command contains any array arguments that are declared /// with `.unconditionalRemaining`. internal static var includesUnconditionalArguments: Bool { - ArgumentSet(self, visibility: .private, parent: .root).contains(where: { + ArgumentSet(self, visibility: .private, parent: nil).contains(where: { $0.isRepeatingPositional && $0.parsingStrategy == .allRemainingInput }) } diff --git a/Sources/ArgumentParser/Parsing/ArgumentDefinition.swift b/Sources/ArgumentParser/Parsing/ArgumentDefinition.swift index bdbda9a26..d1036b39b 100644 --- a/Sources/ArgumentParser/Parsing/ArgumentDefinition.swift +++ b/Sources/ArgumentParser/Parsing/ArgumentDefinition.swift @@ -217,7 +217,7 @@ extension ArgumentDefinition { /// /// This initializer is used for any property defined on a `ParsableArguments` /// type that isn't decorated with one of ArgumentParser's property wrappers. - init(unparsedKey: String, default defaultValue: Any?, parent: InputKey.Parent) { + init(unparsedKey: String, default defaultValue: Any?, parent: InputKey?) { self.init( container: Bare.self, key: InputKey(name: unparsedKey, parent: parent), diff --git a/Sources/ArgumentParser/Parsing/ArgumentSet.swift b/Sources/ArgumentParser/Parsing/ArgumentSet.swift index 6adfebf99..dc689dc5e 100644 --- a/Sources/ArgumentParser/Parsing/ArgumentSet.swift +++ b/Sources/ArgumentParser/Parsing/ArgumentSet.swift @@ -438,7 +438,7 @@ extension ArgumentSet { func firstPositional( named name: String ) -> ArgumentDefinition? { - let key = InputKey(name: name, parent: .root) + let key = InputKey(name: name, parent: nil) return first(where: { $0.help.keys.contains(key) }) } diff --git a/Sources/ArgumentParser/Parsing/CommandParser.swift b/Sources/ArgumentParser/Parsing/CommandParser.swift index ee327c41f..5c17cf228 100644 --- a/Sources/ArgumentParser/Parsing/CommandParser.swift +++ b/Sources/ArgumentParser/Parsing/CommandParser.swift @@ -140,7 +140,7 @@ extension CommandParser { /// possible. fileprivate mutating func parseCurrent(_ split: inout SplitArguments) throws -> ParsableCommand { // Build the argument set (i.e. information on how to parse): - let commandArguments = ArgumentSet(currentNode.element, visibility: .private, parent: .root) + let commandArguments = ArgumentSet(currentNode.element, visibility: .private, parent: nil) // Parse the arguments, ignoring anything unexpected let values = try commandArguments.lenientParse( @@ -325,7 +325,7 @@ extension CommandParser { let completionValues = Array(args) // Generate the argument set and parse the argument to find in the set - let argset = ArgumentSet(current.element, visibility: .private, parent: .root) + let argset = ArgumentSet(current.element, visibility: .private, parent: nil) let parsedArgument = try! parseIndividualArg(argToMatch, at: 0).first! // Look up the specified argument and retrieve its custom completion function diff --git a/Sources/ArgumentParser/Parsing/InputKey.swift b/Sources/ArgumentParser/Parsing/InputKey.swift index 1d47d17cf..9837e41e0 100644 --- a/Sources/ArgumentParser/Parsing/InputKey.swift +++ b/Sources/ArgumentParser/Parsing/InputKey.swift @@ -9,122 +9,48 @@ // //===----------------------------------------------------------------------===// -/// Represents the path to a parsed field, annotated with ``Flag``, ``Option`` or -/// ``Argument``. It has a parent, which will either be ``InputKey/Parent/root`` -/// if the field is on the root ``ParsableComand`` or ``AsyncParsableCommand``, -/// or it will have a ``InputKey/Parent/key(InputKey)`` if it is defined in -/// a ``ParsableArguments`` instance. +/// Represents the path to a parsed field, annotated with ``Flag``, ``Option`` +/// or ``Argument``. Fields that are directly declared on a ``ParsableComand`` +/// have a path of length 1, while fields that are declared indirectly (and +/// included via an option group) have longer paths. struct InputKey: Hashable { - /// Describes the parent of an ``InputKey``. - indirect enum Parent: Hashable { - /// There is no parent key. - case root - /// There is a parent key. - case key(InputKey) - - /// Initialises a parent depending on whether the key is provided. - init(_ key: InputKey?) { - if let key = key { - self = .key(key) - } else { - self = .root - } - } - } - /// The name of the input key. - let name: String - - /// The parent of this key. - let parent: Parent + var name: String + + /// The path through the field's parents, if any. + var path: [String] + /// The full path of the field. + var fullPath: [String] { path + [name] } - /// Constructs a new ``InputKey``, cleaing the `name`, with the specified ``InputKey/Parent``. + /// Constructs a new input key, cleaning the name, with the specified parent. /// /// - Parameter name: The name of the key. - /// - Parameter parent: The ``InputKey/Parent`` of the key. - init(name: String, parent: Parent) { - self.name = Self.clean(codingKey: name) - self.parent = parent - } - - @inlinable - init?(path: [CodingKey]) { - var parentPath = path - guard let key = parentPath.popLast() else { - return nil - } - self.name = Self.clean(codingKey: key) - self.parent = Parent(InputKey(path: parentPath)) - } - - /// Constructs a new ``InputKey``, "cleaning the `value` and `path` if necessary. - /// - /// - Parameter value: The base value of the key. - /// - Parameter path: The list of ``CodingKey`` values that lead to this one. May be empty. - @inlinable - init(name: String, path: [CodingKey]) { - self.init(name: name, parent: Parent(InputKey(path: path))) + /// - Parameter parent: The input key of the parent. + init(name: String, parent: InputKey?) { + // Property wrappers have underscore-prefixed names, so we remove the + // leading `_`, if present. + self.name = name.first == "_" + ? String(name.dropFirst(1)) + : name + self.path = parent?.fullPath ?? [] } - /// Constructs a new ``InputKey``, "cleaning the `value` and `path` if necessary. + /// Constructs a new input key from the given coding key and parent path. /// - /// - Parameter codingKey: The base ``CodingKey`` - /// - Parameter path: The list of ``CodingKey`` values that lead to this one. May be empty. + /// - Parameter codingKey: The base ``CodingKey``. Leading underscores in + /// `codingKey` is preserved. + /// - Parameter path: The list of ``CodingKey`` values that lead to this one. + /// `path` may be empty. @inlinable init(codingKey: CodingKey, path: [CodingKey]) { - self.init(name: codingKey.stringValue, parent: Parent(InputKey(path: path))) - } - - /// The full path, including the ``parent`` and the ``name``. - var fullPath: [String] { - switch parent { - case .root: - return [name] - case .key(let key): - var parentPath = key.fullPath - parentPath.append(name) - return parentPath - } - } - - /// Returns a new ``InputKey`` with the same ``path`` and a new ``name``. - /// The new value will be cleaned. - /// - /// - Parameter newName: The new ``String`` value. - /// - Returns: A new ``InputKey`` with the cleaned value and the same ``path``. - func with(newName: String) -> InputKey { - return .init(name: Self.clean(codingKey: newName), parent: self.parent) - } -} - -extension InputKey { - /// Property wrappers have underscore-prefixed names, so this returns a "clean" - /// version of the `codingKey`, which has the leading `'_'` removed, if present. - /// - /// - Parameter codingKey: The key to clean. - /// - Returns: The cleaned key. - static func clean(codingKey: String) -> String { - String(codingKey.first == "_" ? codingKey.dropFirst(1) : codingKey.dropFirst(0)) - } - - /// Property wrappers have underscore-prefixed names, so this returns a "clean" - /// version of the `codingKey`, which has the leading `'_'` removed, if present. - /// - /// - Parameter codingKey: The key to clean. - /// - Returns: The cleaned key. - static func clean(codingKey: CodingKey) -> String { - clean(codingKey: codingKey.stringValue) + self.name = codingKey.stringValue + self.path = path.map { $0.stringValue } } } extension InputKey: CustomStringConvertible { var description: String { - switch parent { - case .key(let parent): - return "\(parent).\(name)" - case .root: - return name - } + fullPath.joined(separator: ".") } } diff --git a/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift b/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift index 658fc9efe..efd0442ab 100644 --- a/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift +++ b/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift @@ -38,7 +38,7 @@ fileprivate extension BidirectionalCollection where Element == ParsableCommand.T /// Returns the ArgumentSet for the last command in this stack, including /// help and version flags, when appropriate. func allArguments() -> ArgumentSet { - guard var arguments = self.last.map({ ArgumentSet($0, visibility: .private, parent: .root) }) + guard var arguments = self.last.map({ ArgumentSet($0, visibility: .private, parent: nil) }) else { return ArgumentSet() } self.versionArgumentDefinition().map { arguments.append($0) } self.helpArgumentDefinition().map { arguments.append($0) } diff --git a/Sources/ArgumentParser/Usage/HelpGenerator.swift b/Sources/ArgumentParser/Usage/HelpGenerator.swift index 7bc6020c6..440f73f3e 100644 --- a/Sources/ArgumentParser/Usage/HelpGenerator.swift +++ b/Sources/ArgumentParser/Usage/HelpGenerator.swift @@ -97,7 +97,7 @@ internal struct HelpGenerator { fatalError() } - let currentArgSet = ArgumentSet(currentCommand, visibility: visibility, parent: .root) + let currentArgSet = ArgumentSet(currentCommand, visibility: visibility, parent: nil) self.commandStack = commandStack // Build the tool name and subcommand name from the command configuration @@ -292,7 +292,7 @@ fileprivate extension NameSpecification { /// step, the name are returned in descending order. func generateHelpNames(visibility: ArgumentVisibility) -> [Name] { self - .makeNames(InputKey(name: "help", parent: .root)) + .makeNames(InputKey(name: "help", parent: nil)) .compactMap { name in guard visibility.base != .default else { return name } switch name { @@ -333,7 +333,7 @@ internal extension BidirectionalCollection where Element == ParsableCommand.Type options: [.isOptional], help: "Show the version.", defaultValue: nil, - key: InputKey(name: "", parent: .root), + key: InputKey(name: "", parent: nil), isComposite: false), completion: .default, update: .nullary({ _, _, _ in }) @@ -350,7 +350,7 @@ internal extension BidirectionalCollection where Element == ParsableCommand.Type options: [.isOptional], help: "Show help information.", defaultValue: nil, - key: InputKey(name: "", parent: .root), + key: InputKey(name: "", parent: nil), isComposite: false), completion: .default, update: .nullary({ _, _, _ in }) @@ -365,7 +365,7 @@ internal extension BidirectionalCollection where Element == ParsableCommand.Type options: [.isOptional], help: ArgumentHelp("Dump help information as JSON."), defaultValue: nil, - key: InputKey(name: "", parent: .root), + key: InputKey(name: "", parent: nil), isComposite: false), completion: .default, update: .nullary({ _, _, _ in }) @@ -375,7 +375,7 @@ internal extension BidirectionalCollection where Element == ParsableCommand.Type /// Returns the ArgumentSet for the last command in this stack, including /// help and version flags, when appropriate. func argumentsForHelp(visibility: ArgumentVisibility) -> ArgumentSet { - guard var arguments = self.last.map({ ArgumentSet($0, visibility: visibility, parent: .root) }) + guard var arguments = self.last.map({ ArgumentSet($0, visibility: visibility, parent: nil) }) else { return ArgumentSet() } self.versionArgumentDefinition().map { arguments.append($0) } self.helpArgumentDefinition().map { arguments.append($0) } diff --git a/Sources/ArgumentParser/Usage/MessageInfo.swift b/Sources/ArgumentParser/Usage/MessageInfo.swift index 68d4a01cd..896c565b7 100644 --- a/Sources/ArgumentParser/Usage/MessageInfo.swift +++ b/Sources/ArgumentParser/Usage/MessageInfo.swift @@ -122,7 +122,7 @@ enum MessageInfo { guard case ParserError.noArguments = parserError else { return usage } return "\n" + HelpGenerator(commandStack: [type.asCommand], visibility: .default).rendered() }() - let argumentSet = ArgumentSet(commandStack.last!, visibility: .default, parent: .root) + let argumentSet = ArgumentSet(commandStack.last!, visibility: .default, parent: nil) let message = argumentSet.errorDescription(error: parserError) ?? "" let helpAbstract = argumentSet.helpDescription(error: parserError) ?? "" self = .validation(message: message, usage: usage, help: helpAbstract) diff --git a/Sources/ArgumentParser/Usage/UsageGenerator.swift b/Sources/ArgumentParser/Usage/UsageGenerator.swift index b80bdee15..baf46c044 100644 --- a/Sources/ArgumentParser/Usage/UsageGenerator.swift +++ b/Sources/ArgumentParser/Usage/UsageGenerator.swift @@ -22,7 +22,7 @@ extension UsageGenerator { self.init(toolName: toolName, definition: definition) } - init(toolName: String, parsable: ParsableArguments, visibility: ArgumentVisibility, parent: InputKey.Parent) { + init(toolName: String, parsable: ParsableArguments, visibility: ArgumentVisibility, parent: InputKey?) { self.init( toolName: toolName, definition: ArgumentSet(type(of: parsable), visibility: visibility, parent: parent)) diff --git a/Tests/ArgumentParserEndToEndTests/DefaultsEndToEndTests.swift b/Tests/ArgumentParserEndToEndTests/DefaultsEndToEndTests.swift index 4c396c581..4a8e7b7df 100644 --- a/Tests/ArgumentParserEndToEndTests/DefaultsEndToEndTests.swift +++ b/Tests/ArgumentParserEndToEndTests/DefaultsEndToEndTests.swift @@ -828,3 +828,33 @@ extension DefaultsEndToEndTests { } } } + +extension DefaultsEndToEndTests { + private struct UnderscoredOptional: ParsableCommand { + @Option(name: .customLong("arg")) + var _arg: String? + } + + private struct UnderscoredArray: ParsableCommand { + @Option(name: .customLong("columns"), parsing: .upToNextOption) + var _columns: [String] = [] + } + + func testUnderscoredOptional() throws { + AssertParse(UnderscoredOptional.self, []) { parsed in + XCTAssertNil(parsed._arg) + } + AssertParse(UnderscoredOptional.self, ["--arg", "foo"]) { parsed in + XCTAssertEqual(parsed._arg, "foo") + } + } + + func testUnderscoredArray() throws { + AssertParse(UnderscoredArray.self, []) { parsed in + XCTAssertEqual(parsed._columns, []) + } + AssertParse(UnderscoredArray.self, ["--columns", "foo", "bar", "baz"]) { parsed in + XCTAssertEqual(parsed._columns, ["foo", "bar", "baz"]) + } + } +} diff --git a/Tests/ArgumentParserEndToEndTests/OptionGroupEndToEndTests.swift b/Tests/ArgumentParserEndToEndTests/OptionGroupEndToEndTests.swift index c47ac2776..0968157c8 100644 --- a/Tests/ArgumentParserEndToEndTests/OptionGroupEndToEndTests.swift +++ b/Tests/ArgumentParserEndToEndTests/OptionGroupEndToEndTests.swift @@ -109,52 +109,66 @@ extension OptionGroupEndToEndTests { } } -fileprivate struct DuplicatedFlagOption: ParsableArguments { +fileprivate struct DuplicatedFlagGroupCustom: ParsableArguments { @Flag(name: .customLong("duplicated-option")) var duplicated: Bool = false - - enum CodingKeys: CodingKey { - case duplicated - } } -fileprivate struct DuplicatedFlagCommand: ParsableCommand { +fileprivate struct DuplicatedFlagGroupCustomCommand: ParsableCommand { + @Flag var duplicated: Bool = false + @OptionGroup var option: DuplicatedFlagGroupCustom +} + +fileprivate struct DuplicatedFlagGroupLong: ParsableArguments { + @Flag var duplicated: Bool = false +} - @Flag +fileprivate struct DuplicatedFlagGroupLongCommand: ParsableCommand { + @Flag(name: .customLong("duplicated-option")) var duplicated: Bool = false - - @OptionGroup var option: DuplicatedFlagOption - - enum CodingKeys: CodingKey { - case duplicated - case option - } + @OptionGroup var option: DuplicatedFlagGroupLong } extension OptionGroupEndToEndTests { func testUniqueNamesForDuplicatedFlag_NoFlags() throws { - AssertParseCommand(DuplicatedFlagCommand.self, DuplicatedFlagCommand.self, []) { command in + AssertParse(DuplicatedFlagGroupCustomCommand.self, []) { command in + XCTAssertFalse(command.duplicated) + XCTAssertFalse(command.option.duplicated) + } + AssertParse(DuplicatedFlagGroupLongCommand.self, []) { command in XCTAssertFalse(command.duplicated) XCTAssertFalse(command.option.duplicated) } } func testUniqueNamesForDuplicatedFlag_RootOnly() throws { - AssertParseCommand(DuplicatedFlagCommand.self, DuplicatedFlagCommand.self, ["--duplicated"]) { command in + AssertParse(DuplicatedFlagGroupCustomCommand.self, ["--duplicated"]) { command in XCTAssertTrue(command.duplicated) XCTAssertFalse(command.option.duplicated) } + AssertParse(DuplicatedFlagGroupLongCommand.self, ["--duplicated"]) { command in + XCTAssertFalse(command.duplicated) + XCTAssertTrue(command.option.duplicated) + } } func testUniqueNamesForDuplicatedFlag_OptionOnly() throws { - AssertParseCommand(DuplicatedFlagCommand.self, DuplicatedFlagCommand.self, ["--duplicated-option"]) { command in + AssertParse(DuplicatedFlagGroupCustomCommand.self, ["--duplicated-option"]) { command in XCTAssertFalse(command.duplicated) XCTAssertTrue(command.option.duplicated) } + AssertParse(DuplicatedFlagGroupLongCommand.self, ["--duplicated-option"]) { command in + XCTAssertTrue(command.duplicated) + XCTAssertFalse(command.option.duplicated) + } } func testUniqueNamesForDuplicatedFlag_RootAndOption() throws { - AssertParseCommand(DuplicatedFlagCommand.self, DuplicatedFlagCommand.self, ["--duplicated", "--duplicated-option"]) { command in + AssertParse(DuplicatedFlagGroupCustomCommand.self, ["--duplicated", "--duplicated-option"]) { command in + XCTAssertTrue(command.duplicated) + XCTAssertTrue(command.option.duplicated) + } + AssertParse(DuplicatedFlagGroupLongCommand.self, ["--duplicated", "--duplicated-option"]) { command in XCTAssertTrue(command.duplicated) XCTAssertTrue(command.option.duplicated) } diff --git a/Tests/ArgumentParserUnitTests/HelpGenerationTests.swift b/Tests/ArgumentParserUnitTests/HelpGenerationTests.swift index 1b3d7435c..476eb67a8 100644 --- a/Tests/ArgumentParserUnitTests/HelpGenerationTests.swift +++ b/Tests/ArgumentParserUnitTests/HelpGenerationTests.swift @@ -638,7 +638,7 @@ extension HelpGenerationTests { } func testAllValues() { - let opts = ArgumentSet(AllValues.self, visibility: .private, parent: .root) + let opts = ArgumentSet(AllValues.self, visibility: .private, parent: nil) XCTAssertEqual(AllValues.Manual.allValueStrings, opts[0].help.allValues) XCTAssertEqual(AllValues.Manual.allValueStrings, opts[1].help.allValues) diff --git a/Tests/ArgumentParserUnitTests/NameSpecificationTests.swift b/Tests/ArgumentParserUnitTests/NameSpecificationTests.swift index b0b6c905d..328b77afb 100644 --- a/Tests/ArgumentParserUnitTests/NameSpecificationTests.swift +++ b/Tests/ArgumentParserUnitTests/NameSpecificationTests.swift @@ -17,7 +17,7 @@ final class NameSpecificationTests: XCTestCase { extension NameSpecificationTests { func testFlagNames_withNoPrefix() { - let key = InputKey(name: "index", parent: .root) + let key = InputKey(name: "index", parent: nil) XCTAssertEqual(FlagInversion.prefixedNo.enableDisableNamePair(for: key, name: .customLong("foo")).1, [.long("no-foo")]) XCTAssertEqual(FlagInversion.prefixedNo.enableDisableNamePair(for: key, name: .customLong("foo-bar-baz")).1, [.long("no-foo-bar-baz")]) @@ -26,7 +26,7 @@ extension NameSpecificationTests { } func testFlagNames_withEnableDisablePrefix() { - let key = InputKey(name: "index", parent: .root) + let key = InputKey(name: "index", parent: nil) XCTAssertEqual(FlagInversion.prefixedEnableDisable.enableDisableNamePair(for: key, name: .long).0, [.long("enable-index")]) XCTAssertEqual(FlagInversion.prefixedEnableDisable.enableDisableNamePair(for: key, name: .long).1, [.long("disable-index")]) @@ -42,7 +42,7 @@ extension NameSpecificationTests { } } -fileprivate func Assert(nameSpecification: NameSpecification, key: String, parent: InputKey.Parent = .root, makeNames expected: [Name], file: StaticString = #file, line: UInt = #line) { +fileprivate func Assert(nameSpecification: NameSpecification, key: String, parent: InputKey? = nil, makeNames expected: [Name], file: StaticString = #file, line: UInt = #line) { let names = nameSpecification.makeNames(InputKey(name: key, parent: parent)) Assert(names: names, expected: expected, file: file, line: line) } diff --git a/Tests/ArgumentParserUnitTests/ParsableArgumentsValidationTests.swift b/Tests/ArgumentParserUnitTests/ParsableArgumentsValidationTests.swift index cb2ba5f6e..16a6ecfb4 100644 --- a/Tests/ArgumentParserUnitTests/ParsableArgumentsValidationTests.swift +++ b/Tests/ArgumentParserUnitTests/ParsableArgumentsValidationTests.swift @@ -81,7 +81,7 @@ final class ParsableArgumentsValidationTests: XCTestCase { } func testCodingKeyValidation() throws { - let parent = InputKey.Parent.key(InputKey(name: "parentKey", parent: .root)) + let parent = InputKey(name: "parentKey", parent: nil) XCTAssertNil(ParsableArgumentsCodingKeyValidator.validate(A.self, parent: parent)) XCTAssertNil(ParsableArgumentsCodingKeyValidator.validate(B.self, parent: parent)) @@ -130,7 +130,7 @@ final class ParsableArgumentsValidationTests: XCTestCase { } func testCustomDecoderValidation() throws { - let parent = InputKey.Parent(InputKey(name: "foo", parent: .root)) + let parent = InputKey(name: "foo", parent: nil) if let error = ParsableArgumentsCodingKeyValidator.validate(TypeWithInvalidDecoder.self, parent: parent) as? ParsableArgumentsCodingKeyValidator.InvalidDecoderError { @@ -211,7 +211,7 @@ final class ParsableArgumentsValidationTests: XCTestCase { } func testPositionalArgumentsValidation() throws { - let parent = InputKey.Parent(InputKey(name: "foo", parent: .root)) + let parent = InputKey(name: "foo", parent: nil) XCTAssertNil(PositionalArgumentsValidator.validate(A.self, parent: parent)) XCTAssertNil(PositionalArgumentsValidator.validate(F.self, parent: parent)) XCTAssertNil(PositionalArgumentsValidator.validate(H.self, parent: parent)) @@ -246,7 +246,7 @@ final class ParsableArgumentsValidationTests: XCTestCase { } func testUniqueNamesValidation_NoViolation() throws { - let parent = InputKey.Parent(InputKey(name: "foo", parent: .root)) + let parent = InputKey(name: "foo", parent: nil) XCTAssertNil(ParsableArgumentsUniqueNamesValidator.validate(DifferentNames.self, parent: parent)) } @@ -260,7 +260,7 @@ final class ParsableArgumentsValidationTests: XCTestCase { } func testUniqueNamesValidation_TwoOfSameName() throws { - if let error = ParsableArgumentsUniqueNamesValidator.validate(TwoOfTheSameName.self, parent: .root) + if let error = ParsableArgumentsUniqueNamesValidator.validate(TwoOfTheSameName.self, parent: nil) as? ParsableArgumentsUniqueNamesValidator.Error { XCTAssertEqual(error.description, "Multiple (2) `Option` or `Flag` arguments are named \"--foo\".") @@ -288,7 +288,7 @@ final class ParsableArgumentsValidationTests: XCTestCase { } func testUniqueNamesValidation_TwoDuplications() throws { - let parent = InputKey.Parent(InputKey(name: "option", parent: .root)) + let parent = InputKey(name: "option", parent: nil) if let error = ParsableArgumentsUniqueNamesValidator.validate(MultipleUniquenessViolations.self, parent: parent) as? ParsableArgumentsUniqueNamesValidator.Error { @@ -324,7 +324,7 @@ final class ParsableArgumentsValidationTests: XCTestCase { } func testUniqueNamesValidation_ArgumentHasMultipleNames() throws { - if let error = ParsableArgumentsUniqueNamesValidator.validate(MultipleNamesPerArgument.self, parent: .root) + if let error = ParsableArgumentsUniqueNamesValidator.validate(MultipleNamesPerArgument.self, parent: nil) as? ParsableArgumentsUniqueNamesValidator.Error { XCTAssertEqual(error.description, "Multiple (2) `Option` or `Flag` arguments are named \"-v\".") @@ -355,7 +355,7 @@ final class ParsableArgumentsValidationTests: XCTestCase { } func testUniqueNamesValidation_MoreThanTwoDuplications() throws { - if let error = ParsableArgumentsUniqueNamesValidator.validate(FourDuplicateNames.self, parent: .root) + if let error = ParsableArgumentsUniqueNamesValidator.validate(FourDuplicateNames.self, parent: nil) as? ParsableArgumentsUniqueNamesValidator.Error { XCTAssertEqual(error.description, "Multiple (4) `Option` or `Flag` arguments are named \"--foo\".") @@ -397,7 +397,7 @@ final class ParsableArgumentsValidationTests: XCTestCase { } func testUniqueNamesValidation_DuplicatedFlagFirstLetters_ShortNames() throws { - if let error = ParsableArgumentsUniqueNamesValidator.validate(DuplicatedFirstLettersShortNames.self, parent: .root) + if let error = ParsableArgumentsUniqueNamesValidator.validate(DuplicatedFirstLettersShortNames.self, parent: nil) as? ParsableArgumentsUniqueNamesValidator.Error { XCTAssertEqual(error.description, "Multiple (3) `Option` or `Flag` arguments are named \"-f\".") @@ -407,7 +407,7 @@ final class ParsableArgumentsValidationTests: XCTestCase { } func testUniqueNamesValidation_DuplicatedFlagFirstLetters_LongNames() throws { - XCTAssertNil(ParsableArgumentsUniqueNamesValidator.validate(DuplicatedFirstLettersLongNames.self, parent: .root)) + XCTAssertNil(ParsableArgumentsUniqueNamesValidator.validate(DuplicatedFirstLettersLongNames.self, parent: nil)) } fileprivate struct HasOneNonsenseFlag: ParsableCommand { @@ -439,7 +439,7 @@ final class ParsableArgumentsValidationTests: XCTestCase { } func testNonsenseFlagsValidation_OneFlag() throws { - if let error = NonsenseFlagsValidator.validate(HasOneNonsenseFlag.self, parent: .root) + if let error = NonsenseFlagsValidator.validate(HasOneNonsenseFlag.self, parent: nil) as? NonsenseFlagsValidator.Error { XCTAssertEqual( @@ -476,7 +476,7 @@ final class ParsableArgumentsValidationTests: XCTestCase { } func testNonsenseFlagsValidation_MultipleFlags() throws { - if let error = NonsenseFlagsValidator.validate(MultipleNonsenseFlags.self, parent: .root) + if let error = NonsenseFlagsValidator.validate(MultipleNonsenseFlags.self, parent: nil) as? NonsenseFlagsValidator.Error { XCTAssertEqual( diff --git a/Tests/ArgumentParserUnitTests/UsageGenerationTests.swift b/Tests/ArgumentParserUnitTests/UsageGenerationTests.swift index 7f57f6102..2c605aab8 100644 --- a/Tests/ArgumentParserUnitTests/UsageGenerationTests.swift +++ b/Tests/ArgumentParserUnitTests/UsageGenerationTests.swift @@ -22,7 +22,7 @@ func _testSynopsis( file: StaticString = #file, line: UInt = #line ) { - let help = UsageGenerator(toolName: "example", parsable: T(), visibility: visibility, parent: .root) + let help = UsageGenerator(toolName: "example", parsable: T(), visibility: visibility, parent: nil) XCTAssertEqual(help.synopsis, expected, file: file, line: line) } From f674173291ef1dc5bd51f63175179ffb3f6a97fd Mon Sep 17 00:00:00 2001 From: Nate Cook Date: Wed, 8 Feb 2023 21:02:46 -0800 Subject: [PATCH 3/8] Don't let .allUnrecognized consume built-in flags (#550) Arguments declared with the `.allUnrecognized` parsing strategy currently capture built-in flags, which isn't intended. This fixes that issue by looking for built-in flags in the captured portion of the input before decoding. Fixes rdar://104990388 --- .../Parsable Types/ParsableCommand.swift | 12 +- .../ArgumentParser/Parsing/ArgumentSet.swift | 520 +++++++++--------- .../Parsing/CommandParser.swift | 18 +- .../ArgumentParser/Parsing/ParsedValues.swift | 3 + .../Parsing/SplitArguments.swift | 8 +- ...peatingEndToEndTests+ParsingStrategy.swift | 46 +- 6 files changed, 332 insertions(+), 275 deletions(-) diff --git a/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift b/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift index af835a007..17c04fdc7 100644 --- a/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift +++ b/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift @@ -165,17 +165,23 @@ extension ParsableCommand { extension ParsableCommand { /// `true` if this command contains any array arguments that are declared /// with `.unconditionalRemaining`. - internal static var includesUnconditionalArguments: Bool { + internal static var includesPassthroughArguments: Bool { ArgumentSet(self, visibility: .private, parent: nil).contains(where: { $0.isRepeatingPositional && $0.parsingStrategy == .allRemainingInput }) } + internal static var includesAllUnrecognizedArgument: Bool { + ArgumentSet(self, visibility: .private, parent: nil).contains(where: { + $0.isRepeatingPositional && $0.parsingStrategy == .allUnrecognized + }) + } + /// `true` if this command's default subcommand contains any array arguments /// that are declared with `.unconditionalRemaining`. This is `false` if /// there's no default subcommand. - internal static var defaultIncludesUnconditionalArguments: Bool { - configuration.defaultSubcommand?.includesUnconditionalArguments == true + internal static var defaultIncludesPassthroughArguments: Bool { + configuration.defaultSubcommand?.includesPassthroughArguments == true } #if DEBUG diff --git a/Sources/ArgumentParser/Parsing/ArgumentSet.swift b/Sources/ArgumentParser/Parsing/ArgumentSet.swift index dc689dc5e..8b99ca5fe 100644 --- a/Sources/ArgumentParser/Parsing/ArgumentSet.swift +++ b/Sources/ArgumentParser/Parsing/ArgumentSet.swift @@ -160,256 +160,6 @@ extension ArgumentSet { } } -// MARK: - Parsing from SplitArguments -extension ArgumentSet { - /// Parse the given input for this set of defined arguments. - /// - /// This method will consume only the arguments that it understands. If any - /// arguments are declared to capture all remaining input, or a subcommand - /// is configured as such, parsing stops on the first positional argument or - /// unrecognized dash-prefixed argument. - /// - /// - Parameter input: The input that needs to be parsed. - /// - Parameter subcommands: Any subcommands of the current command. - /// - Parameter defaultCapturesAll: `true` if the default subcommand has an - /// argument that captures all remaining input. - func lenientParse( - _ input: SplitArguments, - subcommands: [ParsableCommand.Type], - defaultCapturesAll: Bool - ) throws -> ParsedValues { - // Create a local, mutable copy of the arguments: - var inputArguments = input - - func parseValue( - _ argument: ArgumentDefinition, - _ parsed: ParsedArgument, - _ originElement: InputOrigin.Element, - _ update: ArgumentDefinition.Update.Unary, - _ result: inout ParsedValues, - _ usedOrigins: inout InputOrigin - ) throws { - let origin = InputOrigin(elements: [originElement]) - switch argument.parsingStrategy { - case .default: - // We need a value for this option. - if let value = parsed.value { - // This was `--foo=bar` style: - try update(origin, parsed.name, value, &result) - usedOrigins.formUnion(origin) - } else if argument.allowsJoinedValue, - let (origin2, value) = inputArguments.extractJoinedElement(at: originElement) - { - // Found a joined argument - let origins = origin.inserting(origin2) - try update(origins, parsed.name, String(value), &result) - usedOrigins.formUnion(origins) - } else if let (origin2, value) = inputArguments.popNextElementIfValue(after: originElement) { - // Use `popNextElementIfValue(after:)` to handle cases where short option - // labels are combined - let origins = origin.inserting(origin2) - try update(origins, parsed.name, value, &result) - usedOrigins.formUnion(origins) - } else { - throw ParserError.missingValueForOption(origin, parsed.name) - } - - case .scanningForValue: - // We need a value for this option. - if let value = parsed.value { - // This was `--foo=bar` style: - try update(origin, parsed.name, value, &result) - usedOrigins.formUnion(origin) - } else if argument.allowsJoinedValue, - let (origin2, value) = inputArguments.extractJoinedElement(at: originElement) { - // Found a joined argument - let origins = origin.inserting(origin2) - try update(origins, parsed.name, String(value), &result) - usedOrigins.formUnion(origins) - } else if let (origin2, value) = inputArguments.popNextValue(after: originElement) { - // Use `popNext(after:)` to handle cases where short option - // labels are combined - let origins = origin.inserting(origin2) - try update(origins, parsed.name, value, &result) - usedOrigins.formUnion(origins) - } else { - throw ParserError.missingValueForOption(origin, parsed.name) - } - - case .unconditional: - // Use an attached value if it exists... - if let value = parsed.value { - // This was `--foo=bar` style: - try update(origin, parsed.name, value, &result) - usedOrigins.formUnion(origin) - } else if argument.allowsJoinedValue, - let (origin2, value) = inputArguments.extractJoinedElement(at: originElement) { - // Found a joined argument - let origins = origin.inserting(origin2) - try update(origins, parsed.name, String(value), &result) - usedOrigins.formUnion(origins) - } else { - guard let (origin2, value) = inputArguments.popNextElementAsValue(after: originElement) else { - throw ParserError.missingValueForOption(origin, parsed.name) - } - let origins = origin.inserting(origin2) - try update(origins, parsed.name, value, &result) - usedOrigins.formUnion(origins) - } - - case .allRemainingInput: - // Reset initial value with the found input origins: - try argument.initial(origin, &result) - - // Use an attached value if it exists... - if let value = parsed.value { - // This was `--foo=bar` style: - try update(origin, parsed.name, value, &result) - usedOrigins.formUnion(origin) - } else if argument.allowsJoinedValue, - let (origin2, value) = inputArguments.extractJoinedElement(at: originElement) { - // Found a joined argument - let origins = origin.inserting(origin2) - try update(origins, parsed.name, String(value), &result) - usedOrigins.formUnion(origins) - inputArguments.removeAll(in: usedOrigins) - } - - // ...and then consume the rest of the arguments - while let (origin2, value) = inputArguments.popNextElementAsValue(after: originElement) { - let origins = origin.inserting(origin2) - try update(origins, parsed.name, value, &result) - usedOrigins.formUnion(origins) - } - - case .upToNextOption: - // Use an attached value if it exists... - if let value = parsed.value { - // This was `--foo=bar` style: - try update(origin, parsed.name, value, &result) - usedOrigins.formUnion(origin) - } else if argument.allowsJoinedValue, - let (origin2, value) = inputArguments.extractJoinedElement(at: originElement) { - // Found a joined argument - let origins = origin.inserting(origin2) - try update(origins, parsed.name, String(value), &result) - usedOrigins.formUnion(origins) - inputArguments.removeAll(in: usedOrigins) - } - - // Clear out the initial origin first, since it can include - // the exploded elements of an options group (see issue #327). - usedOrigins.formUnion(origin) - inputArguments.removeAll(in: origin) - - // Fix incorrect error message - // for @Option array without values (see issue #434). - guard let first = inputArguments.elements.first, - first.isValue - else { - throw ParserError.missingValueForOption(origin, parsed.name) - } - - // ...and then consume the arguments until hitting an option - while let (origin2, value) = inputArguments.popNextElementIfValue() { - let origins = origin.inserting(origin2) - try update(origins, parsed.name, value, &result) - usedOrigins.formUnion(origins) - } - - case .postTerminator, .allUnrecognized: - // These parsing kinds are for arguments only. - throw ParserError.invalidState - } - } - - // If this argument set includes a positional argument that unconditionally - // captures all remaining input, we use a different behavior, where we - // shortcut out at the first sign of a positional argument or unrecognized - // option/flag label. - let capturesAll = defaultCapturesAll || self.contains(where: { arg in - arg.isRepeatingPositional && arg.parsingStrategy == .allRemainingInput - }) - - var result = ParsedValues(elements: [:], originalInput: input.originalInput) - var allUsedOrigins = InputOrigin() - - try setInitialValues(into: &result) - - // Loop over all arguments: - ArgumentLoop: - while let (origin, next) = inputArguments.popNext() { - var usedOrigins = InputOrigin() - defer { - inputArguments.removeAll(in: usedOrigins) - allUsedOrigins.formUnion(usedOrigins) - } - - switch next.value { - case .value(let argument): - // Special handling for matching subcommand names. We generally want - // parsing to skip over unrecognized input, but if the current - // command or the matched subcommand captures all remaining input, - // then we want to break out of parsing at this point. - if let matchedSubcommand = subcommands.first(where: { $0._commandName == argument }) { - if !matchedSubcommand.includesUnconditionalArguments && defaultCapturesAll { - continue ArgumentLoop - } else if matchedSubcommand.includesUnconditionalArguments { - break ArgumentLoop - } - } - - // If we're capturing all, the first positional value represents the - // start of positional input. - if capturesAll { break ArgumentLoop } - // We'll parse positional values later. - break - case let .option(parsed): - // Look for an argument that matches this `--option` or `-o`-style - // input. If we can't find one, just move on to the next input. We - // defer catching leftover arguments until we've fully extracted all - // the information for the selected command. - guard let argument = first(matching: parsed) else - { - // If we're capturing all, an unrecognized option/flag is the start - // of positional input. However, the first time we see an option - // pack (like `-fi`) it looks like a long name with a single-dash - // prefix, which may not match an argument even if its subcomponents - // will match. - if capturesAll && parsed.subarguments.isEmpty { break ArgumentLoop } - - // Otherwise, continue parsing. This option/flag may get picked up - // by a child command. - continue - } - - switch argument.update { - case let .nullary(update): - // We don’t expect a value for this option. - guard parsed.value == nil else { - throw ParserError.unexpectedValueForOption(origin, parsed.name, parsed.value!) - } - try update([origin], parsed.name, &result) - usedOrigins.insert(origin) - case let .unary(update): - try parseValue(argument, parsed, origin, update, &result, &usedOrigins) - } - case .terminator: - // Ignore the terminator, it might get picked up as a positional value later. - break - } - } - - // We have parsed all non-positional values at this point. - // Next: parse / consume the positional values. - var unusedArguments = input - unusedArguments.removeAll(in: allUsedOrigins) - try parsePositionalValues(from: unusedArguments, into: &result) - - return result - } -} - extension ArgumentSet { /// Fills the given `ParsedValues` instance with initial values from this /// argument set. @@ -441,8 +191,177 @@ extension ArgumentSet { let key = InputKey(name: name, parent: nil) return first(where: { $0.help.keys.contains(key) }) } +} + +/// A parser for a given input and set of arguments defined by the given +/// command. +/// +/// This parser will consume only the arguments that it understands. If any +/// arguments are declared to capture all remaining input, or a subcommand +/// is configured as such, parsing stops on the first positional argument or +/// unrecognized dash-prefixed argument. +struct LenientParser { + var command: ParsableCommand.Type + var argumentSet: ArgumentSet + var inputArguments: SplitArguments - func parsePositionalValues( + init(_ command: ParsableCommand.Type, _ split: SplitArguments) { + self.command = command + self.argumentSet = ArgumentSet(command, visibility: .private, parent: nil) + self.inputArguments = split + } + + var defaultCapturesForPassthrough: Bool { + command.defaultIncludesPassthroughArguments + } + + var subcommands: [ParsableCommand.Type] { + command.configuration.subcommands + } + + mutating func parseValue( + _ argument: ArgumentDefinition, + _ parsed: ParsedArgument, + _ originElement: InputOrigin.Element, + _ update: ArgumentDefinition.Update.Unary, + _ result: inout ParsedValues, + _ usedOrigins: inout InputOrigin + ) throws { + let origin = InputOrigin(elements: [originElement]) + switch argument.parsingStrategy { + case .default: + // We need a value for this option. + if let value = parsed.value { + // This was `--foo=bar` style: + try update(origin, parsed.name, value, &result) + usedOrigins.formUnion(origin) + } else if argument.allowsJoinedValue, + let (origin2, value) = inputArguments.extractJoinedElement(at: originElement) + { + // Found a joined argument + let origins = origin.inserting(origin2) + try update(origins, parsed.name, String(value), &result) + usedOrigins.formUnion(origins) + } else if let (origin2, value) = inputArguments.popNextElementIfValue(after: originElement) { + // Use `popNextElementIfValue(after:)` to handle cases where short option + // labels are combined + let origins = origin.inserting(origin2) + try update(origins, parsed.name, value, &result) + usedOrigins.formUnion(origins) + } else { + throw ParserError.missingValueForOption(origin, parsed.name) + } + + case .scanningForValue: + // We need a value for this option. + if let value = parsed.value { + // This was `--foo=bar` style: + try update(origin, parsed.name, value, &result) + usedOrigins.formUnion(origin) + } else if argument.allowsJoinedValue, + let (origin2, value) = inputArguments.extractJoinedElement(at: originElement) { + // Found a joined argument + let origins = origin.inserting(origin2) + try update(origins, parsed.name, String(value), &result) + usedOrigins.formUnion(origins) + } else if let (origin2, value) = inputArguments.popNextValue(after: originElement) { + // Use `popNext(after:)` to handle cases where short option + // labels are combined + let origins = origin.inserting(origin2) + try update(origins, parsed.name, value, &result) + usedOrigins.formUnion(origins) + } else { + throw ParserError.missingValueForOption(origin, parsed.name) + } + + case .unconditional: + // Use an attached value if it exists... + if let value = parsed.value { + // This was `--foo=bar` style: + try update(origin, parsed.name, value, &result) + usedOrigins.formUnion(origin) + } else if argument.allowsJoinedValue, + let (origin2, value) = inputArguments.extractJoinedElement(at: originElement) { + // Found a joined argument + let origins = origin.inserting(origin2) + try update(origins, parsed.name, String(value), &result) + usedOrigins.formUnion(origins) + } else { + guard let (origin2, value) = inputArguments.popNextElementAsValue(after: originElement) else { + throw ParserError.missingValueForOption(origin, parsed.name) + } + let origins = origin.inserting(origin2) + try update(origins, parsed.name, value, &result) + usedOrigins.formUnion(origins) + } + + case .allRemainingInput: + // Reset initial value with the found input origins: + try argument.initial(origin, &result) + + // Use an attached value if it exists... + if let value = parsed.value { + // This was `--foo=bar` style: + try update(origin, parsed.name, value, &result) + usedOrigins.formUnion(origin) + } else if argument.allowsJoinedValue, + let (origin2, value) = inputArguments.extractJoinedElement(at: originElement) { + // Found a joined argument + let origins = origin.inserting(origin2) + try update(origins, parsed.name, String(value), &result) + usedOrigins.formUnion(origins) + inputArguments.removeAll(in: usedOrigins) + } + + // ...and then consume the rest of the arguments + while let (origin2, value) = inputArguments.popNextElementAsValue(after: originElement) { + let origins = origin.inserting(origin2) + try update(origins, parsed.name, value, &result) + usedOrigins.formUnion(origins) + } + + case .upToNextOption: + // Use an attached value if it exists... + if let value = parsed.value { + // This was `--foo=bar` style: + try update(origin, parsed.name, value, &result) + usedOrigins.formUnion(origin) + } else if argument.allowsJoinedValue, + let (origin2, value) = inputArguments.extractJoinedElement(at: originElement) { + // Found a joined argument + let origins = origin.inserting(origin2) + try update(origins, parsed.name, String(value), &result) + usedOrigins.formUnion(origins) + inputArguments.removeAll(in: usedOrigins) + } + + // Clear out the initial origin first, since it can include + // the exploded elements of an options group (see issue #327). + usedOrigins.formUnion(origin) + inputArguments.removeAll(in: origin) + + // Fix incorrect error message + // for @Option array without values (see issue #434). + guard let first = inputArguments.elements.first, + first.isValue + else { + throw ParserError.missingValueForOption(origin, parsed.name) + } + + // ...and then consume the arguments until hitting an option + while let (origin2, value) = inputArguments.popNextElementIfValue() { + let origins = origin.inserting(origin2) + try update(origins, parsed.name, value, &result) + usedOrigins.formUnion(origins) + } + + case .postTerminator, .allUnrecognized: + // These parsing kinds are for arguments only. + throw ParserError.invalidState + } + } + + mutating func parsePositionalValues( from unusedInput: SplitArguments, into result: inout ParsedValues ) throws { @@ -451,7 +370,7 @@ extension ArgumentSet { // If this argument set includes a definition that should collect all the // post-terminator inputs, capture them before trying to fill other // `@Argument` definitions. - if let postTerminatorArg = self.first(where: { def in + if let postTerminatorArg = argumentSet.first(where: { def in def.isRepeatingPositional && def.parsingStrategy == .postTerminator }), case let .unary(update) = postTerminatorArg.update, @@ -489,7 +408,7 @@ extension ArgumentSet { // For all positional arguments, consume one or more inputs. var usedOrigins = InputOrigin() ArgumentLoop: - for argumentDefinition in self { + for argumentDefinition in argumentSet { guard case .positional = argumentDefinition.kind else { continue } switch argumentDefinition.parsingStrategy { case .default, .allRemainingInput: @@ -514,11 +433,14 @@ extension ArgumentSet { } // If there's an `.allUnrecognized` argument array, collect leftover args. - if let allUnrecognizedArg = self.first(where: { def in + if let allUnrecognizedArg = argumentSet.first(where: { def in def.isRepeatingPositional && def.parsingStrategy == .allUnrecognized }), case let .unary(update) = allUnrecognizedArg.update { + result.capturedUnrecognizedArguments = SplitArguments( + _elements: Array(argumentStack), + originalInput: []) while let arg = argumentStack.popFirst() { let origin: InputOrigin.Element = .argumentIndex(arg.index) let value = unusedInput.originalInput(at: origin)! @@ -526,4 +448,94 @@ extension ArgumentSet { } } } + + mutating func parse() throws -> ParsedValues { + let originalInput = inputArguments + defer { inputArguments = originalInput } + + // If this argument set includes a positional argument that unconditionally + // captures all remaining input, we use a different behavior, where we + // shortcut out at the first sign of a positional argument or unrecognized + // option/flag label. + let capturesForPassthrough = defaultCapturesForPassthrough || argumentSet.contains(where: { arg in + arg.isRepeatingPositional && arg.parsingStrategy == .allRemainingInput + }) + + var result = ParsedValues(elements: [:], originalInput: inputArguments.originalInput) + var allUsedOrigins = InputOrigin() + + try argumentSet.setInitialValues(into: &result) + + // Loop over all arguments: + ArgumentLoop: + while let (origin, next) = inputArguments.popNext() { + var usedOrigins = InputOrigin() + defer { + inputArguments.removeAll(in: usedOrigins) + allUsedOrigins.formUnion(usedOrigins) + } + + switch next.value { + case .value(let argument): + // Special handling for matching subcommand names. We generally want + // parsing to skip over unrecognized input, but if the current + // command or the matched subcommand captures all remaining input, + // then we want to break out of parsing at this point. + if let matchedSubcommand = subcommands.first(where: { $0._commandName == argument }) { + if !matchedSubcommand.includesPassthroughArguments && defaultCapturesForPassthrough { + continue ArgumentLoop + } else if matchedSubcommand.includesPassthroughArguments { + break ArgumentLoop + } + } + + // If we're capturing all, the first positional value represents the + // start of positional input. + if capturesForPassthrough { break ArgumentLoop } + // We'll parse positional values later. + break + case let .option(parsed): + // Look for an argument that matches this `--option` or `-o`-style + // input. If we can't find one, just move on to the next input. We + // defer catching leftover arguments until we've fully extracted all + // the information for the selected command. + guard let argument = argumentSet.first(matching: parsed) else + { + // If we're capturing all, an unrecognized option/flag is the start + // of positional input. However, the first time we see an option + // pack (like `-fi`) it looks like a long name with a single-dash + // prefix, which may not match an argument even if its subcomponents + // will match. + if capturesForPassthrough && parsed.subarguments.isEmpty { break ArgumentLoop } + + // Otherwise, continue parsing. This option/flag may get picked up + // by a child command. + continue + } + + switch argument.update { + case let .nullary(update): + // We don’t expect a value for this option. + guard parsed.value == nil else { + throw ParserError.unexpectedValueForOption(origin, parsed.name, parsed.value!) + } + try update([origin], parsed.name, &result) + usedOrigins.insert(origin) + case let .unary(update): + try parseValue(argument, parsed, origin, update, &result, &usedOrigins) + } + case .terminator: + // Ignore the terminator, it might get picked up as a positional value later. + break + } + } + + // We have parsed all non-positional values at this point. + // Next: parse / consume the positional values. + var unusedArguments = originalInput + unusedArguments.removeAll(in: allUsedOrigins) + try parsePositionalValues(from: unusedArguments, into: &result) + + return result + } } diff --git a/Sources/ArgumentParser/Parsing/CommandParser.swift b/Sources/ArgumentParser/Parsing/CommandParser.swift index 5c17cf228..4aec2ab7f 100644 --- a/Sources/ArgumentParser/Parsing/CommandParser.swift +++ b/Sources/ArgumentParser/Parsing/CommandParser.swift @@ -139,15 +139,17 @@ extension CommandParser { /// Extracts the current command from `split`, throwing if decoding isn't /// possible. fileprivate mutating func parseCurrent(_ split: inout SplitArguments) throws -> ParsableCommand { - // Build the argument set (i.e. information on how to parse): - let commandArguments = ArgumentSet(currentNode.element, visibility: .private, parent: nil) - // Parse the arguments, ignoring anything unexpected - let values = try commandArguments.lenientParse( - split, - subcommands: currentNode.element.configuration.subcommands, - defaultCapturesAll: currentNode.element.defaultIncludesUnconditionalArguments) - + var parser = LenientParser(currentNode.element, split) + let values = try parser.parse() + + if currentNode.element.includesAllUnrecognizedArgument { + // If this command includes an all-unrecognized argument, any built-in + // flags will have been parsed into that argument. Check for flags + // before decoding. + try checkForBuiltInFlags(values.capturedUnrecognizedArguments) + } + // Decode the values from ParsedValues into the ParsableCommand: let decoder = ArgumentDecoder(values: values, previouslyDecoded: decodedArguments) var decodedResult: ParsableCommand diff --git a/Sources/ArgumentParser/Parsing/ParsedValues.swift b/Sources/ArgumentParser/Parsing/ParsedValues.swift index 7336b1e43..6f8c3ca51 100644 --- a/Sources/ArgumentParser/Parsing/ParsedValues.swift +++ b/Sources/ArgumentParser/Parsing/ParsedValues.swift @@ -28,6 +28,9 @@ struct ParsedValues { /// /// This is used for error output generation. var originalInput: [String] + + /// Any arguments that are captured into an `.allUnrecognized` argument. + var capturedUnrecognizedArguments = SplitArguments(originalInput: []) } extension ParsedValues { diff --git a/Sources/ArgumentParser/Parsing/SplitArguments.swift b/Sources/ArgumentParser/Parsing/SplitArguments.swift index fdf1ab7d2..d5e7ce8d3 100644 --- a/Sources/ArgumentParser/Parsing/SplitArguments.swift +++ b/Sources/ArgumentParser/Parsing/SplitArguments.swift @@ -186,6 +186,8 @@ struct SplitArguments { } } +extension SplitArguments: Equatable {} + extension SplitArguments.Element: CustomDebugStringConvertible { var debugDescription: String { switch value { @@ -272,14 +274,14 @@ extension SplitArguments { } /// Returns the position in `elements` of the given input origin. - mutating func position(of origin: InputOrigin.Element) -> Int? { + func position(of origin: InputOrigin.Element) -> Int? { guard case let .argumentIndex(index) = origin else { return nil } return elements.firstIndex(where: { $0.index == index }) } /// Returns the position in `elements` of the first element after the given /// input origin. - mutating func position(after origin: InputOrigin.Element) -> Int? { + func position(after origin: InputOrigin.Element) -> Int? { guard case let .argumentIndex(index) = origin else { return nil } return elements.firstIndex(where: { $0.index > index }) } @@ -295,7 +297,7 @@ extension SplitArguments { return (.argumentIndex(element.index), element) } - mutating func extractJoinedElement(at origin: InputOrigin.Element) -> (InputOrigin.Element, String)? { + func extractJoinedElement(at origin: InputOrigin.Element) -> (InputOrigin.Element, String)? { guard case let .argumentIndex(index) = origin else { return nil } // Joined arguments only apply when parsing the first sub-element of a diff --git a/Tests/ArgumentParserEndToEndTests/RepeatingEndToEndTests+ParsingStrategy.swift b/Tests/ArgumentParserEndToEndTests/RepeatingEndToEndTests+ParsingStrategy.swift index 457f3bbaf..19149da76 100644 --- a/Tests/ArgumentParserEndToEndTests/RepeatingEndToEndTests+ParsingStrategy.swift +++ b/Tests/ArgumentParserEndToEndTests/RepeatingEndToEndTests+ParsingStrategy.swift @@ -11,14 +11,19 @@ import XCTest import ArgumentParserTestHelpers -import ArgumentParser +@testable import ArgumentParser // MARK: - allUnrecognized -fileprivate struct AllUnrecognizedArgs: ParsableArguments { +fileprivate struct AllUnrecognizedArgs: ParsableCommand { + static var configuration: CommandConfiguration { + .init(version: "1.0") + } + @Flag var verbose: Bool = false @Flag(name: .customShort("f")) var useFiles: Bool = false @Flag(name: .customShort("i")) var useStandardInput: Bool = false + @Flag(name: .customShort("h")) var hoopla: Bool = false @Option var config = "debug" @Argument(parsing: .allUnrecognized) var names: [String] = [] } @@ -27,20 +32,33 @@ extension RepeatingEndToEndTests { func testParsing_repeatingAllUnrecognized() throws { AssertParse(AllUnrecognizedArgs.self, []) { cmd in XCTAssertFalse(cmd.verbose) + XCTAssertFalse(cmd.hoopla) XCTAssertEqual(cmd.names, []) } AssertParse(AllUnrecognizedArgs.self, ["foo", "--verbose", "-fi", "bar", "-z", "--other"]) { cmd in XCTAssertTrue(cmd.verbose) XCTAssertTrue(cmd.useFiles) XCTAssertTrue(cmd.useStandardInput) + XCTAssertFalse(cmd.hoopla) XCTAssertEqual(cmd.names, ["foo", "bar", "-z", "--other"]) } - AssertParse(AllUnrecognizedArgs.self, []) { cmd in - XCTAssertFalse(cmd.verbose) - XCTAssertEqual(cmd.names, []) - } } + + func testParsing_repeatingAllUnrecognized_Builtin() throws { + AssertParse(AllUnrecognizedArgs.self, ["foo", "--verbose", "bar", "-z", "-h"]) { cmd in + XCTAssertTrue(cmd.verbose) + XCTAssertFalse(cmd.useFiles) + XCTAssertFalse(cmd.useStandardInput) + XCTAssertTrue(cmd.hoopla) + XCTAssertEqual(cmd.names, ["foo", "bar", "-z"]) + } + AssertParseCommand(AllUnrecognizedArgs.self, HelpCommand.self, ["foo", "--verbose", "bar", "-z", "--help"]) { cmd in + // No need to test HelpCommand properties + } + XCTAssertThrowsError(try AllUnrecognizedArgs.parse(["foo", "--verbose", "--version"])) + } + func testParsing_repeatingAllUnrecognized_Fails() throws { // Only partially matches the `-fib` argument XCTAssertThrowsError(try PassthroughArgs.parse(["-fib"])) @@ -140,7 +158,7 @@ extension RepeatingEndToEndTests { // MARK: - captureForPassthrough -fileprivate struct PassthroughArgs: ParsableArguments { +fileprivate struct PassthroughArgs: ParsableCommand { @Flag var verbose: Bool = false @Flag(name: .customShort("f")) var useFiles: Bool = false @Flag(name: .customShort("i")) var useStandardInput: Bool = false @@ -220,6 +238,20 @@ extension RepeatingEndToEndTests { XCTAssertFalse(cmd.useStandardInput) XCTAssertEqual(cmd.names, ["-one", "-two", "-three", "-if"]) } + + AssertParse(PassthroughArgs.self, ["-one", "-two", "-three", "-if", "--help"]) { cmd in + XCTAssertFalse(cmd.verbose) + XCTAssertFalse(cmd.useFiles) + XCTAssertFalse(cmd.useStandardInput) + XCTAssertEqual(cmd.names, ["-one", "-two", "-three", "-if", "--help"]) + } + + AssertParse(PassthroughArgs.self, ["-one", "-two", "-three", "-if", "-h"]) { cmd in + XCTAssertFalse(cmd.verbose) + XCTAssertFalse(cmd.useFiles) + XCTAssertFalse(cmd.useStandardInput) + XCTAssertEqual(cmd.names, ["-one", "-two", "-three", "-if", "-h"]) + } } func testParsing_repeatingCaptureForPassthrough_Fails() throws { From fee6933f37fde9a5e12a1e4aeaa93fe60116ff2a Mon Sep 17 00:00:00 2001 From: Nate Cook Date: Thu, 9 Feb 2023 09:25:51 -0800 Subject: [PATCH 4/8] Update changelog for 1.2.2 release (#549) --- CHANGELOG.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6abc30909..08bae3033 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,18 @@ Add new items at the end of the relevant section under **Unreleased**. --- +## [1.2.2] - 2023-02-09 + +### Fixes + +- Arguments with the `.allUnrecognized` parsing strategy no longer consume + built-in flags like `--help` and `--version`. ([#550]) +- Fixes an issue introduced in version 1.2.0 where properties with underscored + names couldn't be parsed. ([#548]) +- Improves the error message for cases where platform availability causes the + synchronous `ParsableCommand.main()` static method to be run on an + `AsyncParsableCommand` type. ([#547]) + ## [1.2.1] - 2023-01-12 ### Changes @@ -764,7 +776,8 @@ This changelog's format is based on [Keep a Changelog](https://keepachangelog.co -[Unreleased]: https://github.com/apple/swift-argument-parser/compare/1.2.1...HEAD +[Unreleased]: https://github.com/apple/swift-argument-parser/compare/1.2.2...HEAD +[1.2.2]: https://github.com/apple/swift-argument-parser/compare/1.2.1...1.2.2 [1.2.1]: https://github.com/apple/swift-argument-parser/compare/1.2.0...1.2.1 [1.2.0]: https://github.com/apple/swift-argument-parser/compare/1.1.4...1.2.0 [1.1.4]: https://github.com/apple/swift-argument-parser/compare/1.1.3...1.1.4 @@ -851,6 +864,9 @@ This changelog's format is based on [Keep a Changelog](https://keepachangelog.co [#522]: https://github.com/apple/swift-argument-parser/pull/522 [#535]: https://github.com/apple/swift-argument-parser/pull/535 [#542]: https://github.com/apple/swift-argument-parser/pull/542 +[#547]: https://github.com/apple/swift-argument-parser/pull/547 +[#548]: https://github.com/apple/swift-argument-parser/pull/548 +[#550]: https://github.com/apple/swift-argument-parser/pull/550 From 5649a380d7de5cb0bab087070a57c477bf6d652e Mon Sep 17 00:00:00 2001 From: Rauhul Varma Date: Tue, 14 Feb 2023 21:54:42 -0800 Subject: [PATCH 5/8] Add subcommand abstracts to single-page manuals (#552) - Fixes a bug where signle-page manuals did not include subcommand abstracts because the DSL logic did not take to account root commands vs subcommands. This change adds a "root" property to the DSL element to allow for styling differences in the two cases. --- .../MathGenerateManualTests.swift | 6 ++++++ Tools/generate-manual/DSL/Document.swift | 2 +- Tools/generate-manual/DSL/SinglePageDescription.swift | 11 ++++++++++- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/Tests/ArgumentParserGenerateManualTests/MathGenerateManualTests.swift b/Tests/ArgumentParserGenerateManualTests/MathGenerateManualTests.swift index 23c332215..ad7ffc20d 100644 --- a/Tests/ArgumentParserGenerateManualTests/MathGenerateManualTests.swift +++ b/Tests/ArgumentParserGenerateManualTests/MathGenerateManualTests.swift @@ -35,6 +35,7 @@ final class MathGenerateManualTests: XCTestCase { .It Fl h , -help Show help information. .It Em add + Print the sum of the values. .Bl -tag -width 6n .It Fl x , -hex-output Use hexadecimal notation for the result. @@ -46,6 +47,7 @@ final class MathGenerateManualTests: XCTestCase { Show help information. .El .It Em multiply + Print the product of the values. .Bl -tag -width 6n .It Fl x , -hex-output Use hexadecimal notation for the result. @@ -57,12 +59,14 @@ final class MathGenerateManualTests: XCTestCase { Show help information. .El .It Em stats + Calculate descriptive statistics. .Bl -tag -width 6n .It Fl -version Show the version. .It Fl h , -help Show help information. .It Em average + Print the average of the values. .Bl -tag -width 6n .It Fl -kind Ar kind The kind of average to provide. @@ -74,6 +78,7 @@ final class MathGenerateManualTests: XCTestCase { Show help information. .El .It Em stdev + Print the standard deviation of the values. .Bl -tag -width 6n .It Ar values... A group of floating-point values to operate on. @@ -83,6 +88,7 @@ final class MathGenerateManualTests: XCTestCase { Show help information. .El .It Em quantiles + Print the quantiles of the values (TBD). .Bl -tag -width 6n .It Ar one-of-four .It Ar custom-arg diff --git a/Tools/generate-manual/DSL/Document.swift b/Tools/generate-manual/DSL/Document.swift index fe80d42e7..86c43aed0 100644 --- a/Tools/generate-manual/DSL/Document.swift +++ b/Tools/generate-manual/DSL/Document.swift @@ -27,7 +27,7 @@ struct Document: MDocComponent { if multiPage { MultiPageDescription(command: command) } else { - SinglePageDescription(command: command) + SinglePageDescription(command: command, root: true) } Exit(section: section) if multiPage { diff --git a/Tools/generate-manual/DSL/SinglePageDescription.swift b/Tools/generate-manual/DSL/SinglePageDescription.swift index dbbe947a2..01b0b2e99 100644 --- a/Tools/generate-manual/DSL/SinglePageDescription.swift +++ b/Tools/generate-manual/DSL/SinglePageDescription.swift @@ -14,6 +14,7 @@ import ArgumentParserToolInfo struct SinglePageDescription: MDocComponent { var command: CommandInfoV0 + var root: Bool var body: MDocComponent { Section(title: "description") { @@ -23,6 +24,14 @@ struct SinglePageDescription: MDocComponent { @MDocBuilder var core: MDocComponent { + if !root, let abstract = command.abstract { + abstract + } + + if !root, command.abstract != nil, command.discussion != nil { + MDocMacro.ParagraphBreak() + } + if let discussion = command.discussion { discussion } @@ -46,7 +55,7 @@ struct SinglePageDescription: MDocComponent { for subcommand in command.subcommands ?? [] { MDocMacro.ListItem(title: MDocMacro.Emphasis(arguments: [subcommand.commandName])) - SinglePageDescription(command: subcommand).core + SinglePageDescription(command: subcommand, root: false).core } } } From c5050aa63ed5bb23209e7120b7ff6618ee06e0ee Mon Sep 17 00:00:00 2001 From: Gwynne Raskind Date: Thu, 23 Feb 2023 14:00:14 -0600 Subject: [PATCH 6/8] Handle unparsed non-optional complex property types (#554) * Implement correct handling of unparsed property types which perform non-optional nested decoding. --- .../Parsing/ArgumentDecoder.swift | 7 ++ .../UnparsedValuesEndToEndTest.swift | 69 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/Sources/ArgumentParser/Parsing/ArgumentDecoder.swift b/Sources/ArgumentParser/Parsing/ArgumentDecoder.swift index 616785dad..81fc0932b 100644 --- a/Sources/ArgumentParser/Parsing/ArgumentDecoder.swift +++ b/Sources/ArgumentParser/Parsing/ArgumentDecoder.swift @@ -103,6 +103,13 @@ final class ParsedArgumentsContainer: KeyedDecodingContainerProtocol where K } func decode(_ type: T.Type, forKey key: K) throws -> T where T : Decodable { + let parsedElement = element(forKey: key) + if parsedElement?.inputOrigin.isDefaultValue ?? false, let rawValue = parsedElement?.value { + guard let value = rawValue as? T else { + throw InternalParseError.wrongType(rawValue, forKey: parsedElement!.key) + } + return value + } let subDecoder = SingleValueDecoder(userInfo: decoder.userInfo, underlying: decoder, codingPath: codingPath + [key], key: InputKey(codingKey: key, path: codingPath), parsedElement: element(forKey: key)) return try type.init(from: subDecoder) } diff --git a/Tests/ArgumentParserEndToEndTests/UnparsedValuesEndToEndTest.swift b/Tests/ArgumentParserEndToEndTests/UnparsedValuesEndToEndTest.swift index ccf2e6c56..e6b6a4e8a 100644 --- a/Tests/ArgumentParserEndToEndTests/UnparsedValuesEndToEndTest.swift +++ b/Tests/ArgumentParserEndToEndTests/UnparsedValuesEndToEndTest.swift @@ -256,3 +256,72 @@ extension UnparsedValuesEndToEndTests { XCTAssertThrowsError(try Bar.parse(["--bar", "--bazz", "xyz", "--age", "None"])) } } + +// MARK: Value + unparsed dictionary + +fileprivate struct Bamf: ParsableCommand { + @Flag var bamph: Bool = false + var bop: [String: String] = [:] + var bopp: [String: [String]] = [:] +} + +extension UnparsedValuesEndToEndTests { + func testUnparsedNestedDictionary() { + AssertParse(Bamf.self, []) { bamf in + XCTAssertFalse(bamf.bamph) + XCTAssertEqual(bamf.bop, [:]) + XCTAssertEqual(bamf.bopp, [:]) + } + } +} + +// MARK: Value + unparsed enum with associated values + +fileprivate struct Qiqi: ParsableCommand { + @Flag var qiqiqi: Bool = false + var qiqii: Qiqii = .q("") +} + +fileprivate enum Qiqii: Codable, Equatable { + // Enums with associated values generate a Codable conformance + // which calls `KeyedDecodingContainer.nestedContainer(keyedBy:)`. + // + // There is no known case of anything ever actually using the + // `.nestedUnkeyedContainer()` method. + case q(String) + case i(Int) +} + +extension UnparsedValuesEndToEndTests { + func testUnparsedEnumWithAssociatedValues() { + AssertParse(Qiqi.self, []) { qiqi in + XCTAssertFalse(qiqi.qiqiqi) + XCTAssertEqual(qiqi.qiqii, .q("")) + } + } +} + +// MARK: Value + nested decodable inheriting class type + +fileprivate struct Fry: ParsableCommand { + @Flag var c: Bool = false + var toksVig: Vig = .init() +} + +fileprivate class Toks: Codable { + var a = "hello" +} + +fileprivate final class Vig: Toks { + var b = "world" +} + +extension UnparsedValuesEndToEndTests { + func testUnparsedNestedInheritingClassType() { + AssertParse(Fry.self, []) { fry in + XCTAssertFalse(fry.c) + XCTAssertEqual(fry.toksVig.a, "hello") + XCTAssertEqual(fry.toksVig.b, "world") + } + } +} From 1ed0ac0925bb223c11f2814270214720f1dd24bd Mon Sep 17 00:00:00 2001 From: Nate Cook Date: Wed, 28 Jun 2023 12:28:39 -0500 Subject: [PATCH 7/8] Switch `count-lines` to macOS 12 only (#576) CI is allowing a `guard #available(macOS 12)` in the tests to pass, but then reporting macOS version 10.16 both in ProcessInfo and in the guard in the actual `CountLines.run()` method, which results in an inconsistency. Switching the minimum platform for `count-lines` until we can sort out the configuration issue. --- Examples/count-lines/CountLines.swift | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/Examples/count-lines/CountLines.swift b/Examples/count-lines/CountLines.swift index 464142b61..63a08746b 100644 --- a/Examples/count-lines/CountLines.swift +++ b/Examples/count-lines/CountLines.swift @@ -13,7 +13,7 @@ import ArgumentParser import Foundation @main -@available(macOS 10.15, *) +@available(macOS 12, *) struct CountLines: AsyncParsableCommand { @Argument( help: "A file to count lines in. If omitted, counts the lines of stdin.", @@ -27,7 +27,7 @@ struct CountLines: AsyncParsableCommand { var verbose = false } -@available(macOS 10.15, *) +@available(macOS 12, *) extension CountLines { var fileHandle: FileHandle { get throws { @@ -58,11 +58,6 @@ extension CountLines { } mutating func run() async throws { - guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { - print("'count-lines' isn't supported on this platform.") - return - } - let countAllLines = prefix == nil let lineCount = try await fileHandle.bytes.lines.reduce(0) { count, line in if countAllLines || line.starts(with: prefix!) { From 7b0f469fd4cb7583fe1f1accc3072399e6b2c122 Mon Sep 17 00:00:00 2001 From: Max Desiatov Date: Wed, 28 Jun 2023 14:02:34 -0400 Subject: [PATCH 8/8] Add support for Musl libc (#574) * Add support for Musl libc Since Musl is sufficiently different from Glibc (see https://wiki.musl-libc.org/functional-differences-from-glibc.html), it requires a different import, which now should be applied to files that have `import Glibc` in them. * Platform.swift: fix macOS build issue Glibc and Darwin can share the same `ioctl` code. --- Sources/ArgumentParser/Utilities/Platform.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/ArgumentParser/Utilities/Platform.swift b/Sources/ArgumentParser/Utilities/Platform.swift index 87d32dc68..b7fa0462f 100644 --- a/Sources/ArgumentParser/Utilities/Platform.swift +++ b/Sources/ArgumentParser/Utilities/Platform.swift @@ -11,6 +11,8 @@ #if canImport(Glibc) import Glibc +#elseif canImport(Musl) +import Musl #elseif canImport(Darwin) import Darwin #elseif canImport(CRT) @@ -78,6 +80,8 @@ extension Platform { static func exit(_ code: Int32) -> Never { #if canImport(Glibc) Glibc.exit(code) +#elseif canImport(Musl) + Musl.exit(code) #elseif canImport(Darwin) Darwin.exit(code) #elseif canImport(CRT) @@ -135,6 +139,8 @@ extension Platform { // TIOCGWINSZ is a complex macro, so we need the flattened value. let tiocgwinsz = Int32(0x40087468) let err = ioctl(STDOUT_FILENO, tiocgwinsz, &w) +#elseif canImport(Musl) + let err = ioctl(STDOUT_FILENO, UInt(TIOCGWINSZ), &w) #else let err = ioctl(STDOUT_FILENO, TIOCGWINSZ, &w) #endif