diff --git a/Sources/ArgumentParser/CMakeLists.txt b/Sources/ArgumentParser/CMakeLists.txt index 4df32fb5f..6b0c1ed42 100644 --- a/Sources/ArgumentParser/CMakeLists.txt +++ b/Sources/ArgumentParser/CMakeLists.txt @@ -21,7 +21,6 @@ add_library(ArgumentParser "Parsable Types/EnumerableFlag.swift" "Parsable Types/ExpressibleByArgument.swift" "Parsable Types/ParsableArguments.swift" - "Parsable Types/ParsableArgumentsValidation.swift" "Parsable Types/ParsableCommand.swift" Parsing/ArgumentDecoder.swift @@ -47,7 +46,13 @@ add_library(ArgumentParser Utilities/Platform.swift Utilities/SequenceExtensions.swift Utilities/StringExtensions.swift - Utilities/Tree.swift) + Utilities/Tree.swift + + Validators/CodingKeyValidator.swift + Validators/NonsenseFlagsValidator.swift + Validators/ParsableArgumentsValidation.swift + Validators/PositionalArgumentsValidator.swift + Validators/UniqueNamesValidator.swift) # NOTE: workaround for CMake not setting up include flags yet set_target_properties(ArgumentParser PROPERTIES INTERFACE_INCLUDE_DIRECTORIES ${CMAKE_Swift_MODULE_DIRECTORY}) diff --git a/Sources/ArgumentParser/Parsable Properties/Argument.swift b/Sources/ArgumentParser/Parsable Properties/Argument.swift index 43c61a711..c1f5e64fb 100644 --- a/Sources/ArgumentParser/Parsable Properties/Argument.swift +++ b/Sources/ArgumentParser/Parsable Properties/Argument.swift @@ -82,7 +82,7 @@ public struct Argument: case .value(let v): return v case .definition: - fatalError(directlyInitializedError) + configurationFailure(directlyInitializedError) } } set { diff --git a/Sources/ArgumentParser/Parsable Properties/Flag.swift b/Sources/ArgumentParser/Parsable Properties/Flag.swift index 90dcdb54f..e24558cb6 100644 --- a/Sources/ArgumentParser/Parsable Properties/Flag.swift +++ b/Sources/ArgumentParser/Parsable Properties/Flag.swift @@ -107,7 +107,7 @@ public struct Flag: Decodable, ParsedWrapper { case .value(let v): return v case .definition: - fatalError(directlyInitializedError) + configurationFailure(directlyInitializedError) } } set { diff --git a/Sources/ArgumentParser/Parsable Properties/Option.swift b/Sources/ArgumentParser/Parsable Properties/Option.swift index 15d8106ab..a73187fc1 100644 --- a/Sources/ArgumentParser/Parsable Properties/Option.swift +++ b/Sources/ArgumentParser/Parsable Properties/Option.swift @@ -87,7 +87,7 @@ public struct Option: Decodable, ParsedWrapper { case .value(let v): return v case .definition: - fatalError(directlyInitializedError) + configurationFailure(directlyInitializedError) } } set { diff --git a/Sources/ArgumentParser/Parsable Properties/OptionGroup.swift b/Sources/ArgumentParser/Parsable Properties/OptionGroup.swift index 3fbf22cbc..7a244bb0e 100644 --- a/Sources/ArgumentParser/Parsable Properties/OptionGroup.swift +++ b/Sources/ArgumentParser/Parsable Properties/OptionGroup.swift @@ -101,7 +101,7 @@ public struct OptionGroup: Decodable, ParsedWrapper { case .value(let v): return v case .definition: - fatalError(directlyInitializedError) + configurationFailure(directlyInitializedError) } } set { diff --git a/Sources/ArgumentParser/Parsable Types/ParsableArguments.swift b/Sources/ArgumentParser/Parsable Types/ParsableArguments.swift index de0ab7ce9..24fa5e6f1 100644 --- a/Sources/ArgumentParser/Parsable Types/ParsableArguments.swift +++ b/Sources/ArgumentParser/Parsable Types/ParsableArguments.swift @@ -290,7 +290,7 @@ extension ArgumentSet { do { try type._validate(parent: parent) } catch { - assertionFailure("\(error)") + configurationFailure("\(error)") } #endif @@ -321,11 +321,23 @@ extension ArgumentSet { } } +/// Prints the given message to standard error and exits with a failure code. +/// +/// - Parameter message: The message to print to standard error. `message` +/// should be pre-wrapped, if desired. +func configurationFailure(_ message: String) -> Never { + var errorOut = Platform.standardError + print("\n", to: &errorOut) + print(String(repeating: "-", count: 70), to: &errorOut) + print(message, to: &errorOut) + print(String(repeating: "-", count: 70), to: &errorOut) + print("\n", to: &errorOut) + Platform.exit(Platform.exitCodeFailure) +} + /// The fatal error message to display when someone accesses a /// `ParsableArguments` type after initializing it directly. internal let directlyInitializedError = """ - - -------------------------------------------------------------------- Can't read a value from a parsable argument definition. This error indicates that a property declared with an `@Argument`, @@ -335,6 +347,4 @@ internal let directlyInitializedError = """ To get a valid value, either call one of the static parsing methods (`parse`, `parseAsRoot`, or `main`) or define an initializer that initializes _every_ property of your parsable type. - -------------------------------------------------------------------- - """ diff --git a/Sources/ArgumentParser/Parsable Types/ParsableArgumentsValidation.swift b/Sources/ArgumentParser/Parsable Types/ParsableArgumentsValidation.swift deleted file mode 100644 index 94605adf4..000000000 --- a/Sources/ArgumentParser/Parsable Types/ParsableArgumentsValidation.swift +++ /dev/null @@ -1,355 +0,0 @@ -//===----------------------------------------------------------*- swift -*-===// -// -// 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 -// -//===----------------------------------------------------------------------===// - -private protocol ParsableArgumentsValidator { - static func validate(_ type: ParsableArguments.Type, parent: InputKey?) - -> ParsableArgumentsValidatorError? -} - -enum ValidatorErrorKind { - case warning - case failure -} - -protocol ParsableArgumentsValidatorError: Error { - var kind: ValidatorErrorKind { get } -} - -struct ParsableArgumentsValidationError: Error, CustomStringConvertible { - let parsableArgumentsType: ParsableArguments.Type - let underlayingErrors: [Error] - var description: String { - """ - Validation failed for `\(parsableArgumentsType)`: - - \(underlayingErrors.map({"- \($0)"}).joined(separator: "\n")) - - - """ - } -} - -extension ParsableArguments { - static func _validate(parent: InputKey?) throws { - let validators: [ParsableArgumentsValidator.Type] = [ - PositionalArgumentsValidator.self, - ParsableArgumentsCodingKeyValidator.self, - ParsableArgumentsUniqueNamesValidator.self, - NonsenseFlagsValidator.self, - ] - let errors = validators.compactMap { validator in - validator.validate(self, parent: parent) - } - if errors.count > 0 { - throw ParsableArgumentsValidationError( - parsableArgumentsType: self, underlayingErrors: errors) - } - } -} - -extension ArgumentSet { - fileprivate var firstPositionalArgument: ArgumentDefinition? { - content.first(where: { $0.isPositional }) - } - - fileprivate var firstRepeatedPositionalArgument: ArgumentDefinition? { - content.first(where: { $0.isRepeatingPositional }) - } -} - -/// A validator for positional argument arrays. -/// -/// For positional arguments to be valid, there must be at most one -/// positional array argument, and it must be the last positional argument -/// in the argument list. Any other configuration leads to ambiguity in -/// parsing the arguments. -struct PositionalArgumentsValidator: ParsableArgumentsValidator { - struct Error: ParsableArgumentsValidatorError, CustomStringConvertible { - let repeatedPositionalArgument: String - - let positionalArgumentFollowingRepeated: String - - var description: String { - """ - Can't have a positional argument \ - `\(positionalArgumentFollowingRepeated)` following an array of \ - positional arguments `\(repeatedPositionalArgument)`. - """ - } - - var kind: ValidatorErrorKind { .failure } - } - - static func validate( - _ type: ParsableArguments.Type, parent: InputKey? - ) -> ParsableArgumentsValidatorError? { - let sets: [ArgumentSet] = Mirror(reflecting: type.init()) - .children - .compactMap { child in - guard - let codingKey = child.label, - let parsed = child.value as? ArgumentSetProvider - else { return nil } - - let key = InputKey(name: codingKey, parent: parent) - return parsed.argumentSet(for: key) - } - - guard - let repeatedPositional = sets.firstIndex(where: { - $0.firstRepeatedPositionalArgument != nil - }) - else { return nil } - guard - let positionalFollowingRepeated = sets[repeatedPositional...] - .dropFirst() - .first(where: { $0.firstPositionalArgument != nil }) - else { return nil } - - // swift-format-ignore: NeverForceUnwrap - // We know these are non-nil because of the guard statements above. - let firstRepeatedPositionalArgument: ArgumentDefinition = sets[ - repeatedPositional - ].firstRepeatedPositionalArgument! - // swift-format-ignore: NeverForceUnwrap - let positionalFollowingRepeatedArgument: ArgumentDefinition = - positionalFollowingRepeated.firstPositionalArgument! - // swift-format-ignore: NeverForceUnwrap - return Error( - repeatedPositionalArgument: firstRepeatedPositionalArgument.help.keys - .first!.name, - positionalArgumentFollowingRepeated: positionalFollowingRepeatedArgument - .help.keys.first!.name) - } -} - -/// A validator that ensures that all arguments have corresponding coding keys. -struct ParsableArgumentsCodingKeyValidator: ParsableArgumentsValidator { - - private struct Validator: Decoder { - let argumentKeys: [InputKey] - - enum ValidationResult: Swift.Error { - case success - case missingCodingKeys([InputKey]) - } - - let codingPath: [CodingKey] = [] - let userInfo: [CodingUserInfoKey: Any] = [:] - - func unkeyedContainer() throws -> UnkeyedDecodingContainer { - fatalError() - } - - func singleValueContainer() throws -> SingleValueDecodingContainer { - fatalError() - } - - func container(keyedBy type: Key.Type) throws - -> KeyedDecodingContainer where Key: CodingKey - { - let missingKeys = argumentKeys.filter { Key(stringValue: $0.name) == nil } - if missingKeys.isEmpty { - throw ValidationResult.success - } else { - throw ValidationResult.missingCodingKeys(missingKeys) - } - } - } - - /// This error indicates that an option, a flag, or an argument of - /// a `ParsableArguments` is defined without a corresponding `CodingKey`. - struct MissingKeysError: ParsableArgumentsValidatorError, - CustomStringConvertible - { - let missingCodingKeys: [InputKey] - - var description: String { - let resolution = """ - To resolve this error, make sure that all properties have corresponding - cases in your custom `CodingKey` enumeration. - """ - - if missingCodingKeys.count > 1 { - return """ - Arguments \(missingCodingKeys.map({ "`\($0)`" }).joined(separator: ",")) \ - are defined without corresponding `CodingKey`s. - - \(resolution) - """ - } else { - return """ - Argument `\(missingCodingKeys[0])` is defined without a corresponding \ - `CodingKey`. - - \(resolution) - """ - } - } - - var kind: ValidatorErrorKind { - .failure - } - } - - struct InvalidDecoderError: ParsableArgumentsValidatorError, - CustomStringConvertible - { - let type: ParsableArguments.Type - - var description: String { - """ - The implementation of `init(from:)` for `\(type)` - is not compatible with ArgumentParser. To resolve this issue, make sure - that `init(from:)` calls the `container(keyedBy:)` method on the given - decoder and decodes each of its properties using the returned decoder. - """ - } - - var kind: ValidatorErrorKind { - .failure - } - } - - static func validate(_ type: ParsableArguments.Type, parent: InputKey?) - -> ParsableArgumentsValidatorError? - { - let argumentKeys: [InputKey] = Mirror(reflecting: type.init()) - .children - .compactMap { child in - guard - let codingKey = child.label, - child.value as? ArgumentSetProvider != nil - else { return nil } - - // Property wrappers have underscore-prefixed names - return InputKey(name: codingKey, parent: parent) - } - guard argumentKeys.count > 0 else { - return nil - } - do { - let _ = try type.init(from: Validator(argumentKeys: argumentKeys)) - return InvalidDecoderError(type: type) - } catch let result as Validator.ValidationResult { - switch result { - case .missingCodingKeys(let keys): - return MissingKeysError(missingCodingKeys: keys) - case .success: - return nil - } - } catch { - fatalError("Unexpected validation error: \(error)") - } - } -} - -/// A validator that ensures argument names are unique within a -/// `ParsableArguments` or `ParsableCommand`. -struct ParsableArgumentsUniqueNamesValidator: ParsableArgumentsValidator { - struct Error: ParsableArgumentsValidatorError, CustomStringConvertible { - var duplicateNames: [String: Int] = [:] - - var description: String { - duplicateNames.map { entry in - "Multiple (\(entry.value)) `Option` or `Flag` arguments are named \"\(entry.key)\"." - }.joined(separator: "\n") - } - - var kind: ValidatorErrorKind { .failure } - } - - static func validate(_ type: ParsableArguments.Type, parent: InputKey?) - -> ParsableArgumentsValidatorError? - { - let argSets: [ArgumentSet] = Mirror(reflecting: type.init()) - .children - .compactMap { child in - guard - let codingKey = child.label, - let parsed = child.value as? ArgumentSetProvider - else { return nil } - - let key = InputKey(name: codingKey, parent: parent) - return parsed.argumentSet(for: key) - } - - let countedNames: [String: Int] = argSets.reduce(into: [:]) { - countedNames, args in - for name in args.content.flatMap({ $0.names }) { - countedNames[name.synopsisString, default: 0] += 1 - } - } - - let duplicateNames = countedNames.filter { $0.value > 1 } - return duplicateNames.isEmpty - ? nil - : Error(duplicateNames: duplicateNames) - } -} - -/// A validator that prevents declaring flags that can't be turned off. -struct NonsenseFlagsValidator: ParsableArgumentsValidator { - struct Error: ParsableArgumentsValidatorError, CustomStringConvertible { - var names: [String] - - var description: String { - """ - One or more Boolean flags is declared with an initial value of `true`. - This results in the flag always being `true`, no matter whether the user - specifies the flag or not. - - To resolve this error, change the default to `false`, provide a value - for the `inversion:` parameter, or remove the `@Flag` property wrapper - altogether. - - Affected flag(s): - \(names.joined(separator: "\n")) - """ - } - - var kind: ValidatorErrorKind { .warning } - } - - static func validate(_ type: ParsableArguments.Type, parent: InputKey?) - -> ParsableArgumentsValidatorError? - { - let argSets: [ArgumentSet] = Mirror(reflecting: type.init()) - .children - .compactMap { child in - guard - let codingKey = child.label, - let parsed = child.value as? ArgumentSetProvider - else { return nil } - - let key = InputKey(name: codingKey, parent: parent) - return parsed.argumentSet(for: key) - } - - let nonsenseFlags: [String] = argSets.flatMap { args -> [String] in - args.compactMap { def in - if case .nullary = def.update, - !def.help.isComposite, - def.help.options.contains(.isOptional), - def.help.defaultValue == "true" - { - return def.unadornedSynopsis - } else { - return nil - } - } - } - - return nonsenseFlags.isEmpty - ? nil - : Error(names: nonsenseFlags) - } -} diff --git a/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift b/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift index 024670aa0..c9c45085d 100644 --- a/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift +++ b/Sources/ArgumentParser/Parsable Types/ParsableCommand.swift @@ -215,17 +215,13 @@ extension ParsableCommand { guard sub.configuration.subcommands.isEmpty else { continue } guard sub is AsyncParsableCommand.Type else { continue } - fatalError( + configurationFailure( """ - - -------------------------------------------------------------------- Asynchronous subcommand of a synchronous root. The asynchronous command `\(sub)` is declared as a subcommand of the synchronous root command `\(root)`. With this configuration, your asynchronous `run()` method will not be called. To fix this issue, change `\(root)`'s `ParsableCommand` conformance to `AsyncParsableCommand`. - -------------------------------------------------------------------- - """.wrapped(to: 70)) } } @@ -256,25 +252,19 @@ extension ParsableCommand { func failAsyncHierarchy( rootCommand: ParsableCommand.Type, subCommand: ParsableCommand.Type ) -> Never { - fatalError( + configurationFailure( """ - - -------------------------------------------------------------------- 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( + configurationFailure( """ - - -------------------------------------------------------------------- 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. @@ -283,7 +273,5 @@ func failAsyncPlatform(rootCommand: ParsableCommand.Type) -> Never { + """ @available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *) - -------------------------------------------------------------------- - """) } diff --git a/Sources/ArgumentParser/Parsing/CommandParser.swift b/Sources/ArgumentParser/Parsing/CommandParser.swift index 89aca8db5..8e4e44afa 100644 --- a/Sources/ArgumentParser/Parsing/CommandParser.swift +++ b/Sources/ArgumentParser/Parsing/CommandParser.swift @@ -48,15 +48,18 @@ struct CommandParser { } catch Tree.InitializationError.recursiveSubcommand( let command) { - fatalError( - "The ParsableCommand \"\(command)\" can't have itself as its own subcommand." - ) - } catch Tree.InitializationError.aliasMatchingCommand( - let command) + configurationFailure( + """ + The command \"\(command)\" can't have itself as its own subcommand. + """.wrapped(to: 70)) + } catch Tree + .InitializationError.aliasMatchingCommand(let command) { - fatalError( - "The ParsableCommand \"\(command)\" can't have an alias with the same name as the command itself." - ) + configurationFailure( + """ + The command \"\(command)\" can't have an alias with the same name \ + as the command itself. + """.wrapped(to: 70)) } catch { fatalError("Unexpected error: \(error).") } diff --git a/Sources/ArgumentParser/Validators/CodingKeyValidator.swift b/Sources/ArgumentParser/Validators/CodingKeyValidator.swift new file mode 100644 index 000000000..daae65e4e --- /dev/null +++ b/Sources/ArgumentParser/Validators/CodingKeyValidator.swift @@ -0,0 +1,130 @@ +//===----------------------------------------------------------*- swift -*-===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// A validator that ensures that all arguments have corresponding coding keys. +struct CodingKeyValidator: ParsableArgumentsValidator { + private struct Validator: Decoder { + let argumentKeys: [InputKey] + + enum ValidationResult: Swift.Error { + case success + case missingCodingKeys([InputKey]) + } + + let codingPath: [CodingKey] = [] + let userInfo: [CodingUserInfoKey: Any] = [:] + + func unkeyedContainer() throws -> UnkeyedDecodingContainer { + fatalError() + } + + func singleValueContainer() throws -> SingleValueDecodingContainer { + fatalError() + } + + func container(keyedBy type: Key.Type) throws + -> KeyedDecodingContainer where Key: CodingKey + { + let missingKeys = argumentKeys.filter { Key(stringValue: $0.name) == nil } + if missingKeys.isEmpty { + throw ValidationResult.success + } else { + throw ValidationResult.missingCodingKeys(missingKeys) + } + } + } + + /// This error indicates that an option, a flag, or an argument of + /// a `ParsableArguments` is defined without a corresponding `CodingKey`. + struct MissingKeysError: ParsableArgumentsValidatorError, + CustomStringConvertible + { + let missingCodingKeys: [InputKey] + + var description: String { + let resolution = """ + To resolve this error, make sure that all properties have \ + corresponding cases in your custom `CodingKey` enumeration. + """ + + if missingCodingKeys.count > 1 { + return """ + Arguments \(missingCodingKeys.map({ "`\($0)`" }).joined(separator: ",")) \ + are defined without corresponding `CodingKey`s. + + \(resolution) + """ + } else { + return """ + Argument `\(missingCodingKeys[0])` is defined without a \ + corresponding `CodingKey`. + + \(resolution) + """ + } + } + + var kind: ValidatorErrorKind { + .failure + } + } + + struct InvalidDecoderError: ParsableArgumentsValidatorError, + CustomStringConvertible + { + let type: ParsableArguments.Type + + var description: String { + """ + The implementation of `init(from:)` for `\(type)` \ + is not compatible with ArgumentParser. To resolve this issue, make sure \ + that `init(from:)` calls the `container(keyedBy:)` method on the given \ + decoder and decodes each of its properties using the returned decoder. + """ + } + + var kind: ValidatorErrorKind { + .failure + } + } + + static func validate(_ type: ParsableArguments.Type, parent: InputKey?) + -> ParsableArgumentsValidatorError? + { + let argumentKeys: [InputKey] = Mirror(reflecting: type.init()) + .children + .compactMap { child in + guard + let codingKey = child.label, + child.value as? ArgumentSetProvider != nil + else { return nil } + + // Property wrappers have underscore-prefixed names + return InputKey(name: codingKey, parent: parent) + } + guard argumentKeys.count > 0 else { + return nil + } + do { + let _ = try type.init(from: Validator(argumentKeys: argumentKeys)) + return InvalidDecoderError(type: type) + } catch let result as Validator.ValidationResult { + switch result { + case .missingCodingKeys(let keys): + return MissingKeysError(missingCodingKeys: keys) + case .success: + return nil + } + } catch { + fatalError("Unexpected validation error: \(error)") + } + } +} diff --git a/Sources/ArgumentParser/Validators/NonsenseFlagsValidator.swift b/Sources/ArgumentParser/Validators/NonsenseFlagsValidator.swift new file mode 100644 index 000000000..0250a659e --- /dev/null +++ b/Sources/ArgumentParser/Validators/NonsenseFlagsValidator.swift @@ -0,0 +1,68 @@ +//===----------------------------------------------------------*- swift -*-===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// A validator that prevents declaring flags that can't be turned off. +struct NonsenseFlagsValidator: ParsableArgumentsValidator { + struct Error: ParsableArgumentsValidatorError, CustomStringConvertible { + var names: [String] + + var description: String { + """ + One or more Boolean flags is declared with an initial value of `true`. \ + This results in the flag always being `true`, no matter whether the user \ + specifies the flag or not. + + To resolve this error, change the default to `false`, provide a value \ + for the `inversion:` parameter, or remove the `@Flag` property wrapper \ + altogether. + + Affected flag(s): + \(names.joined(separator: "\n")) + """ + } + + var kind: ValidatorErrorKind { .warning } + } + + static func validate(_ type: ParsableArguments.Type, parent: InputKey?) + -> ParsableArgumentsValidatorError? + { + let argSets: [ArgumentSet] = Mirror(reflecting: type.init()) + .children + .compactMap { child in + guard + let codingKey = child.label, + let parsed = child.value as? ArgumentSetProvider + else { return nil } + + let key = InputKey(name: codingKey, parent: parent) + return parsed.argumentSet(for: key) + } + + let nonsenseFlags: [String] = argSets.flatMap { args -> [String] in + args.compactMap { def in + if case .nullary = def.update, + !def.help.isComposite, + def.help.options.contains(.isOptional), + def.help.defaultValue == "true" + { + return def.unadornedSynopsis + } else { + return nil + } + } + } + + return nonsenseFlags.isEmpty + ? nil + : Error(names: nonsenseFlags) + } +} diff --git a/Sources/ArgumentParser/Validators/ParsableArgumentsValidation.swift b/Sources/ArgumentParser/Validators/ParsableArgumentsValidation.swift new file mode 100644 index 000000000..cb5c22758 --- /dev/null +++ b/Sources/ArgumentParser/Validators/ParsableArgumentsValidation.swift @@ -0,0 +1,62 @@ +//===----------------------------------------------------------*- swift -*-===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +extension ParsableArguments { + static func _validate(parent: InputKey?) throws { + let validators: [ParsableArgumentsValidator.Type] = [ + PositionalArgumentsValidator.self, + CodingKeyValidator.self, + UniqueNamesValidator.self, + NonsenseFlagsValidator.self, + ] + let errors = validators.compactMap { validator in + validator.validate(self, parent: parent) + } + if errors.count > 0 { + throw ParsableArgumentsValidationError( + parsableArgumentsType: self, underlayingErrors: errors) + } + } +} + +protocol ParsableArgumentsValidator { + static func validate(_ type: ParsableArguments.Type, parent: InputKey?) + -> ParsableArgumentsValidatorError? +} + +enum ValidatorErrorKind { + case warning + case failure +} + +protocol ParsableArgumentsValidatorError: Error { + var kind: ValidatorErrorKind { get } +} + +struct ParsableArgumentsValidationError: Error, CustomStringConvertible { + let parsableArgumentsType: ParsableArguments.Type + let underlayingErrors: [Error] + + var description: String { + let errorDescriptions = + underlayingErrors + .map { + "- \($0)" + .wrapped(to: 68) + .hangingIndentingEachLine(by: 2) + } + return """ + Validation failed for `\(parsableArgumentsType)`: + + \(errorDescriptions.joined(separator: "\n")) + """ + } +} diff --git a/Sources/ArgumentParser/Validators/PositionalArgumentsValidator.swift b/Sources/ArgumentParser/Validators/PositionalArgumentsValidator.swift new file mode 100644 index 000000000..71ec5f09c --- /dev/null +++ b/Sources/ArgumentParser/Validators/PositionalArgumentsValidator.swift @@ -0,0 +1,86 @@ +//===----------------------------------------------------------*- swift -*-===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// A validator for positional argument arrays. +/// +/// For positional arguments to be valid, there must be at most one +/// positional array argument, and it must be the last positional argument +/// in the argument list. Any other configuration leads to ambiguity in +/// parsing the arguments. +struct PositionalArgumentsValidator: ParsableArgumentsValidator { + struct Error: ParsableArgumentsValidatorError, CustomStringConvertible { + let repeatedPositionalArgument: String + + let positionalArgumentFollowingRepeated: String + + var description: String { + """ + Can't have a positional argument \ + `\(positionalArgumentFollowingRepeated)` following an array of \ + positional arguments `\(repeatedPositionalArgument)`. + """ + } + + var kind: ValidatorErrorKind { .failure } + } + + static func validate( + _ type: ParsableArguments.Type, parent: InputKey? + ) -> ParsableArgumentsValidatorError? { + let sets: [ArgumentSet] = Mirror(reflecting: type.init()) + .children + .compactMap { child in + guard + let codingKey = child.label, + let parsed = child.value as? ArgumentSetProvider + else { return nil } + + let key = InputKey(name: codingKey, parent: parent) + return parsed.argumentSet(for: key) + } + + guard + let repeatedPositional = sets.firstIndex(where: { + $0.firstRepeatedPositionalArgument != nil + }) + else { return nil } + guard + let positionalFollowingRepeated = sets[repeatedPositional...] + .dropFirst() + .first(where: { $0.firstPositionalArgument != nil }) + else { return nil } + + // swift-format-ignore: NeverForceUnwrap + // We know these are non-nil because of the guard statements above. + let firstRepeatedPositionalArgument: ArgumentDefinition = sets[ + repeatedPositional + ].firstRepeatedPositionalArgument! + // swift-format-ignore: NeverForceUnwrap + let positionalFollowingRepeatedArgument: ArgumentDefinition = + positionalFollowingRepeated.firstPositionalArgument! + // swift-format-ignore: NeverForceUnwrap + return Error( + repeatedPositionalArgument: firstRepeatedPositionalArgument.help.keys + .first!.name, + positionalArgumentFollowingRepeated: positionalFollowingRepeatedArgument + .help.keys.first!.name) + } +} + +extension ArgumentSet { + fileprivate var firstPositionalArgument: ArgumentDefinition? { + content.first(where: { $0.isPositional }) + } + + fileprivate var firstRepeatedPositionalArgument: ArgumentDefinition? { + content.first(where: { $0.isRepeatingPositional }) + } +} diff --git a/Sources/ArgumentParser/Validators/UniqueNamesValidator.swift b/Sources/ArgumentParser/Validators/UniqueNamesValidator.swift new file mode 100644 index 000000000..2db959e39 --- /dev/null +++ b/Sources/ArgumentParser/Validators/UniqueNamesValidator.swift @@ -0,0 +1,57 @@ +//===----------------------------------------------------------*- swift -*-===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// A validator that ensures argument names are unique within a +/// `ParsableArguments` or `ParsableCommand`. +struct UniqueNamesValidator: ParsableArgumentsValidator { + struct Error: ParsableArgumentsValidatorError, CustomStringConvertible { + var duplicateNames: [String: Int] = [:] + + var description: String { + duplicateNames.map { entry in + """ + Multiple (\(entry.value)) `Option` or `Flag` arguments are named \ + "\(entry.key)". + """ + }.joined(separator: "\n") + } + + var kind: ValidatorErrorKind { .failure } + } + + static func validate(_ type: ParsableArguments.Type, parent: InputKey?) + -> ParsableArgumentsValidatorError? + { + let argSets: [ArgumentSet] = Mirror(reflecting: type.init()) + .children + .compactMap { child in + guard + let codingKey = child.label, + let parsed = child.value as? ArgumentSetProvider + else { return nil } + + let key = InputKey(name: codingKey, parent: parent) + return parsed.argumentSet(for: key) + } + + let countedNames: [String: Int] = argSets.reduce(into: [:]) { + countedNames, args in + for name in args.content.flatMap({ $0.names }) { + countedNames[name.synopsisString, default: 0] += 1 + } + } + + let duplicateNames = countedNames.filter { $0.value > 1 } + return duplicateNames.isEmpty + ? nil + : Error(duplicateNames: duplicateNames) + } +} diff --git a/Tests/ArgumentParserUnitTests/ParsableArgumentsValidationTests.swift b/Tests/ArgumentParserUnitTests/ParsableArgumentsValidationTests.swift index ac7f21b21..90cfcef04 100644 --- a/Tests/ArgumentParserUnitTests/ParsableArgumentsValidationTests.swift +++ b/Tests/ArgumentParserUnitTests/ParsableArgumentsValidationTests.swift @@ -84,13 +84,13 @@ final class ParsableArgumentsValidationTests: XCTestCase { func testCodingKeyValidation() throws { let parent = InputKey(name: "parentKey", parent: nil) XCTAssertNil( - ParsableArgumentsCodingKeyValidator.validate(A.self, parent: parent)) + CodingKeyValidator.validate(A.self, parent: parent)) XCTAssertNil( - ParsableArgumentsCodingKeyValidator.validate(B.self, parent: parent)) + CodingKeyValidator.validate(B.self, parent: parent)) - if let error = ParsableArgumentsCodingKeyValidator.validate( + if let error = CodingKeyValidator.validate( C.self, parent: parent) - as? ParsableArgumentsCodingKeyValidator.MissingKeysError + as? CodingKeyValidator.MissingKeysError { XCTAssert( error.missingCodingKeys == [InputKey(name: "count", parent: parent)]) @@ -98,9 +98,9 @@ final class ParsableArgumentsValidationTests: XCTestCase { XCTFail() } - if let error = ParsableArgumentsCodingKeyValidator.validate( + if let error = CodingKeyValidator.validate( D.self, parent: parent) - as? ParsableArgumentsCodingKeyValidator.MissingKeysError + as? CodingKeyValidator.MissingKeysError { XCTAssert( error.missingCodingKeys == [ @@ -110,9 +110,9 @@ final class ParsableArgumentsValidationTests: XCTestCase { XCTFail() } - if let error = ParsableArgumentsCodingKeyValidator.validate( + if let error = CodingKeyValidator.validate( E.self, parent: parent) - as? ParsableArgumentsCodingKeyValidator.MissingKeysError + as? CodingKeyValidator.MissingKeysError { XCTAssert( error.missingCodingKeys == [ @@ -140,9 +140,9 @@ final class ParsableArgumentsValidationTests: XCTestCase { func testCustomDecoderValidation() throws { let parent = InputKey(name: "foo", parent: nil) - if let error = ParsableArgumentsCodingKeyValidator.validate( + if let error = CodingKeyValidator.validate( TypeWithInvalidDecoder.self, parent: parent) - as? ParsableArgumentsCodingKeyValidator.InvalidDecoderError + as? CodingKeyValidator.InvalidDecoderError { XCTAssert(error.type == TypeWithInvalidDecoder.self) } else { @@ -247,9 +247,9 @@ final class ParsableArgumentsValidationTests: XCTestCase { } } - // MARK: ParsableArgumentsUniqueNamesValidator tests + // MARK: UniqueNamesValidator tests fileprivate let unexpectedErrorMessage = - "Expected error of type `ParsableArgumentsUniqueNamesValidator.Error`, but got something else." + "Expected error of type `UniqueNamesValidator.Error`, but got something else." // MARK: Names are unique fileprivate struct DifferentNames: ParsableArguments { @@ -263,7 +263,7 @@ final class ParsableArgumentsValidationTests: XCTestCase { func testUniqueNamesValidation_NoViolation() throws { let parent = InputKey(name: "foo", parent: nil) XCTAssertNil( - ParsableArgumentsUniqueNamesValidator.validate( + UniqueNamesValidator.validate( DifferentNames.self, parent: parent)) } @@ -277,9 +277,9 @@ final class ParsableArgumentsValidationTests: XCTestCase { } func testUniqueNamesValidation_TwoOfSameName() throws { - if let error = ParsableArgumentsUniqueNamesValidator.validate( + if let error = UniqueNamesValidator.validate( TwoOfTheSameName.self, parent: nil) - as? ParsableArgumentsUniqueNamesValidator.Error + as? UniqueNamesValidator.Error { XCTAssertEqual( error.description, @@ -309,9 +309,9 @@ final class ParsableArgumentsValidationTests: XCTestCase { func testUniqueNamesValidation_TwoDuplications() throws { let parent = InputKey(name: "option", parent: nil) - if let error = ParsableArgumentsUniqueNamesValidator.validate( + if let error = UniqueNamesValidator.validate( MultipleUniquenessViolations.self, parent: parent) - as? ParsableArgumentsUniqueNamesValidator.Error + as? UniqueNamesValidator.Error { XCTAssert( /// The `Mirror` reflects the properties `foo` and `bar` in a random order each time it's built. @@ -345,9 +345,9 @@ final class ParsableArgumentsValidationTests: XCTestCase { } func testUniqueNamesValidation_ArgumentHasMultipleNames() throws { - if let error = ParsableArgumentsUniqueNamesValidator.validate( + if let error = UniqueNamesValidator.validate( MultipleNamesPerArgument.self, parent: nil) - as? ParsableArgumentsUniqueNamesValidator.Error + as? UniqueNamesValidator.Error { XCTAssertEqual( error.description, @@ -379,9 +379,9 @@ final class ParsableArgumentsValidationTests: XCTestCase { } func testUniqueNamesValidation_MoreThanTwoDuplications() throws { - if let error = ParsableArgumentsUniqueNamesValidator.validate( + if let error = UniqueNamesValidator.validate( FourDuplicateNames.self, parent: nil) - as? ParsableArgumentsUniqueNamesValidator.Error + as? UniqueNamesValidator.Error { XCTAssertEqual( error.description, @@ -425,9 +425,9 @@ final class ParsableArgumentsValidationTests: XCTestCase { func testUniqueNamesValidation_DuplicatedFlagFirstLetters_ShortNames() throws { - if let error = ParsableArgumentsUniqueNamesValidator.validate( + if let error = UniqueNamesValidator.validate( DuplicatedFirstLettersShortNames.self, parent: nil) - as? ParsableArgumentsUniqueNamesValidator.Error + as? UniqueNamesValidator.Error { XCTAssertEqual( error.description, @@ -439,7 +439,7 @@ final class ParsableArgumentsValidationTests: XCTestCase { func testUniqueNamesValidation_DuplicatedFlagFirstLetters_LongNames() throws { XCTAssertNil( - ParsableArgumentsUniqueNamesValidator.validate( + UniqueNamesValidator.validate( DuplicatedFirstLettersLongNames.self, parent: nil)) } @@ -479,13 +479,9 @@ final class ParsableArgumentsValidationTests: XCTestCase { XCTAssertEqual( error.description, """ - One or more Boolean flags is declared with an initial value of `true`. - This results in the flag always being `true`, no matter whether the user - specifies the flag or not. + One or more Boolean flags is declared with an initial value of `true`. This results in the flag always being `true`, no matter whether the user specifies the flag or not. - To resolve this error, change the default to `false`, provide a value - for the `inversion:` parameter, or remove the `@Flag` property wrapper - altogether. + To resolve this error, change the default to `false`, provide a value for the `inversion:` parameter, or remove the `@Flag` property wrapper altogether. Affected flag(s): --nonsense @@ -517,13 +513,9 @@ final class ParsableArgumentsValidationTests: XCTestCase { XCTAssertEqual( error.description, """ - One or more Boolean flags is declared with an initial value of `true`. - This results in the flag always being `true`, no matter whether the user - specifies the flag or not. + One or more Boolean flags is declared with an initial value of `true`. This results in the flag always being `true`, no matter whether the user specifies the flag or not. - To resolve this error, change the default to `false`, provide a value - for the `inversion:` parameter, or remove the `@Flag` property wrapper - altogether. + To resolve this error, change the default to `false`, provide a value for the `inversion:` parameter, or remove the `@Flag` property wrapper altogether. Affected flag(s): --stuff