Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion Documentation/05 Validation and Errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ hey

## Handling Post-Validation Errors

The `ValidationError` type is a special `ArgumentParser` error — a validation error's message is always accompanied by an appropriate usage string. You can throw other errors, from either the `validate()` or `run()` method to indicate that something has gone wrong that isn't validation-specific.
The `ValidationError` type is a special `ArgumentParser` error — a validation error's message is always accompanied by an appropriate usage string. You can throw other errors, from either the `validate()` or `run()` method to indicate that something has gone wrong that isn't validation-specific. Errors that conform to `CustomStringConvertible` or `LocalizedError` provide the best experience for users.

```swift
struct LineCount: ParsableCommand {
Expand All @@ -80,3 +80,23 @@ The throwing `String(contentsOfFile:encoding:)` initializer fails when the user
Error: The file “non-existing-file.swift” couldn’t be opened because
there is no such file.
```

If you print your error output yourself, you still need to throw an error from `validate()` or `run()`, so that your command exits with the appropriate exit code. To avoid printing an extra error message, use the `ExitCode` error, which has static properties for success, failure, and validation errors, or lets you specify a specific exit code.

```swift
struct RuntimeError: Error, CustomStringConvertible {
var description: String
}

struct Example: ParsableCommand {
@Argument() var inputFile: String

func run() throws {
if !ExampleCore.processFile(inputFile) {
// ExampleCore.processFile(_:) prints its own errors
// and returns `false` on failure.
throw ExitCode.failure
}
}
}
```
28 changes: 28 additions & 0 deletions Examples/math/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,34 @@ extension Math.Statistics {

@Argument(help: "A group of floating-point values to operate on.")
var values: [Double]

// These args and the validation method are for testing exit codes:
@Flag(help: .hidden)
var testSuccessExitCode: Bool
@Flag(help: .hidden)
var testFailureExitCode: Bool
@Flag(help: .hidden)
var testValidationExitCode: Bool
@Option(help: .hidden)
var testCustomExitCode: Int32?

func validate() throws {
if testSuccessExitCode {
throw ExitCode.success
}

if testFailureExitCode {
throw ExitCode.failure
}

if testValidationExitCode {
throw ExitCode.validationFailure
}

if let exitCode = testCustomExitCode {
throw ExitCode(exitCode)
}
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/ArgumentParser/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
add_library(ArgumentParser
"Parsable Properties/Argument.swift"
"Parsable Properties/ArgumentHelp.swift"
"Parsable Properties/Errors.swift"
"Parsable Properties/Flag.swift"
"Parsable Properties/NameSpecification.swift"
"Parsable Properties/Option.swift"
"Parsable Properties/OptionGroup.swift"
"Parsable Properties/ValidationError.swift"

"Parsable Types/CommandConfiguration.swift"
"Parsable Types/ExpressibleByArgument.swift"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@
//
//===----------------------------------------------------------------------===//

#if canImport(Glibc)
import Glibc
#elseif canImport(Darwin)
import Darwin
#elseif canImport(MSVCRT)
import MSVCRT
#endif

/// An error type that is presented to the user as an error with parsing their
/// command-line input.
public struct ValidationError: Error, CustomStringConvertible {
Expand All @@ -24,6 +32,29 @@ public struct ValidationError: Error, CustomStringConvertible {
}
}

/// An error type that only includes an exit code.
///
/// If you're printing custom errors messages yourself, you can throw this error
/// to specify the exit code without adding any additional output to standard
/// out or standard error.
public struct ExitCode: Error {
var code: Int32

/// Creates a new `ExitCode` with the given code.
public init(_ code: Int32) {
self.code = code
}

/// An exit code that indicates successful completion of a command.
public static let success = ExitCode(EXIT_SUCCESS)

/// An exit code that indicates that the command failed.
public static let failure = ExitCode(EXIT_FAILURE)

/// An exit code that indicates that the user provided invalid input.
public static let validationFailure = ExitCode(EX_USAGE)
}

/// An error type that represents a clean (i.e. non-error state) exit of the
/// utility.
///
Expand Down
12 changes: 7 additions & 5 deletions Sources/ArgumentParser/Parsable Types/ParsableArguments.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,14 +139,16 @@ extension ParsableArguments {
withError error: Error? = nil
) -> Never {
guard let error = error else {
_exit(EXIT_SUCCESS)
_exit(ExitCode.success.code)
}

let messageInfo = MessageInfo(error: error, type: self)
if messageInfo.shouldExitCleanly {
print(messageInfo.fullText)
} else {
print(messageInfo.fullText, to: &standardError)
if !messageInfo.fullText.isEmpty {
if messageInfo.shouldExitCleanly {
print(messageInfo.fullText)
} else {
print(messageInfo.fullText, to: &standardError)
}
}
_exit(messageInfo.exitCode)
}
Expand Down
25 changes: 14 additions & 11 deletions Sources/ArgumentParser/Usage/MessageInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
enum MessageInfo {
case help(text: String)
case validation(message: String, usage: String)
case other(message: String)
case other(message: String, exitCode: Int32)

init(error: Error, type: ParsableArguments.Type) {
var commandStack: [ParsableCommand.Type]
Expand Down Expand Up @@ -61,16 +61,18 @@ enum MessageInfo {
case .message(let message):
self = .help(text: message)
}
case let error as ExitCode:
self = .other(message: "", exitCode: error.code)
case let error as LocalizedError where error.errorDescription != nil:
self = .other(message: error.errorDescription!)
self = .other(message: error.errorDescription!, exitCode: EXIT_FAILURE)
default:
self = .other(message: String(describing: error))
self = .other(message: String(describing: error), exitCode: EXIT_FAILURE)
}
} else if let parserError = parserError {
let message = ArgumentSet(commandStack.last!).helpMessage(for: parserError)
self = .validation(message: message, usage: usage)
} else {
self = .other(message: String(describing: error))
self = .other(message: String(describing: error), exitCode: EXIT_FAILURE)
}
}

Expand All @@ -80,7 +82,7 @@ enum MessageInfo {
return text
case .validation(message: let message, usage: _):
return message
case .other(message: let message):
case .other(let message, _):
return message
}
}
Expand All @@ -90,9 +92,10 @@ enum MessageInfo {
case .help(text: let text):
return text
case .validation(message: let message, usage: let usage):
return "Error: \(message)\n\(usage)"
case .other(message: let message):
return "Error: \(message)"
let errorMessage = message.isEmpty ? "" : "Error: \(message)\n"
return errorMessage + usage
case .other(let message, _):
return message.isEmpty ? "" : "Error: \(message)"
}
}

Expand All @@ -105,9 +108,9 @@ enum MessageInfo {

var exitCode: Int32 {
switch self {
case .help: return EXIT_SUCCESS
case .validation: return EX_USAGE
case .other: return EXIT_FAILURE
case .help: return ExitCode.success.code
case .validation: return ExitCode.validationFailure.code
case .other(_, let exitCode): return exitCode
}
}
}
9 changes: 3 additions & 6 deletions Sources/TestHelpers/TestHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ extension XCTest {
public func AssertExecuteCommand(
command: String,
expected: String? = nil,
shouldError: Bool = false,
exitCode: Int32 = 0,
file: StaticString = #file, line: UInt = #line)
{
let splitCommand = command.split(separator: " ")
Expand Down Expand Up @@ -171,10 +171,7 @@ extension XCTest {
if let expected = expected {
AssertEqualStringsIgnoringTrailingWhitespace(expected, errorActual + outputActual, file: file, line: line)
}
if shouldError {
XCTAssertNotEqual(process.terminationStatus, 0, file: file, line: line)
} else {
XCTAssertEqual(process.terminationStatus, 0, file: file, line: line)
}

XCTAssertEqual(process.terminationStatus, exitCode, file: file, line: line)
}
}
36 changes: 34 additions & 2 deletions Tests/EndToEndTests/ValidationEndToEndTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ fileprivate enum UserValidationError: LocalizedError {
}

fileprivate struct Foo: ParsableArguments {
static var usageString: String = """
Usage: foo [--count <count>] [<names> ...] [--version] [--throw]
"""

@Option()
var count: Int?

Expand All @@ -40,6 +44,15 @@ fileprivate struct Foo: ParsableArguments {
@Flag(name: [.customLong("throw")])
var throwCustomError: Bool

@Flag(help: .hidden)
var showUsageOnly: Bool

@Flag(help: .hidden)
var failValidationSilently: Bool

@Flag(help: .hidden)
var failSilently: Bool

mutating func validate() throws {
if version {
throw CleanExit.message("0.0.1")
Expand All @@ -56,6 +69,18 @@ fileprivate struct Foo: ParsableArguments {
if throwCustomError {
throw UserValidationError.userValidationError
}

if showUsageOnly {
throw ValidationError("")
}

if failValidationSilently {
throw ExitCode.validationFailure
}

if failSilently {
throw ExitCode.failure
}
}
}

Expand All @@ -81,20 +106,27 @@ extension ValidationEndToEndTests {
AssertErrorMessage(Foo.self, [], "Must specify at least one name.")
AssertFullErrorMessage(Foo.self, [], """
Error: Must specify at least one name.
Usage: foo [--count <count>] [<names> ...] [--version] [--throw]
\(Foo.usageString)
""")

AssertErrorMessage(Foo.self, ["--count", "3", "Joe"], """
Number of names (1) doesn't match count (3).
""")
AssertFullErrorMessage(Foo.self, ["--count", "3", "Joe"], """
Error: Number of names (1) doesn't match count (3).
Usage: foo [--count <count>] [<names> ...] [--version] [--throw]
\(Foo.usageString)
""")
}

func testCustomErrorValidation() {
// verify that error description is printed if avaiable via LocalizedError
AssertErrorMessage(Foo.self, ["--throw", "Joe"], UserValidationError.userValidationError.errorDescription!)
}

func testEmptyErrorValidation() {
AssertErrorMessage(Foo.self, ["--show-usage-only", "Joe"], "")
AssertFullErrorMessage(Foo.self, ["--show-usage-only", "Joe"], Foo.usageString)
AssertFullErrorMessage(Foo.self, ["--fail-validation-silently", "Joe"], "")
AssertFullErrorMessage(Foo.self, ["--fail-silently", "Joe"], "")
}
}
25 changes: 22 additions & 3 deletions Tests/ExampleTests/MathExampleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,24 +105,43 @@ final class MathExampleTests: XCTestCase {
Error: Please provide at least one value to calculate the mode.
Usage: math stats average [--kind <kind>] [<values> ...]
""",
shouldError: true)
exitCode: EX_USAGE)
}

func testMath_ExitCodes() throws {
AssertExecuteCommand(
command: "math stats quantiles --test-success-exit-code",
expected: "",
exitCode: EXIT_SUCCESS)
AssertExecuteCommand(
command: "math stats quantiles --test-failure-exit-code",
expected: "",
exitCode: EXIT_FAILURE)
AssertExecuteCommand(
command: "math stats quantiles --test-validation-exit-code",
expected: "",
exitCode: EX_USAGE)
AssertExecuteCommand(
command: "math stats quantiles --test-custom-exit-code 42",
expected: "",
exitCode: 42)
}

func testMath_Fail() throws {
AssertExecuteCommand(
command: "math --foo",
expected: """
Error: Unknown option '--foo'
Usage: math add [--hex-output] [<values> ...]
""",
shouldError: true)
exitCode: EX_USAGE)

AssertExecuteCommand(
command: "math ZZZ",
expected: """
Error: The value 'ZZZ' is invalid for '<values>'
Usage: math add [--hex-output] [<values> ...]
""",
shouldError: true)
exitCode: EX_USAGE)
}
}
6 changes: 3 additions & 3 deletions Tests/ExampleTests/RepeatExampleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,22 +48,22 @@ final class RepeatExampleTests: XCTestCase {
Error: Missing expected argument '<phrase>'
Usage: repeat [--count <count>] [--include-counter] <phrase>
""",
shouldError: true)
exitCode: EX_USAGE)

AssertExecuteCommand(
command: "repeat hello --count",
expected: """
Error: Missing value for '--count <count>'
Usage: repeat [--count <count>] [--include-counter] <phrase>
""",
shouldError: true)
exitCode: EX_USAGE)

AssertExecuteCommand(
command: "repeat hello --count ZZZ",
expected: """
Error: The value 'ZZZ' is invalid for '--count <count>'
Usage: repeat [--count <count>] [--include-counter] <phrase>
""",
shouldError: true)
exitCode: EX_USAGE)
}
}
Loading