Skip to content

Commit 9ebf401

Browse files
authored
Add support for exiting without printing an error. (#51)
This adds an `ExitCode` error type that stores an exit code, and updates the exit machinery to (1) use `ExitCode` as the source for exit codes, and (2) silently exit when the provided error is an `ExitCode` instance.
1 parent 53a00f5 commit 9ebf401

File tree

11 files changed

+166
-34
lines changed

11 files changed

+166
-34
lines changed

Documentation/05 Validation and Errors.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ hey
5757

5858
## Handling Post-Validation Errors
5959

60-
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.
60+
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.
6161

6262
```swift
6363
struct LineCount: ParsableCommand {
@@ -80,3 +80,23 @@ The throwing `String(contentsOfFile:encoding:)` initializer fails when the user
8080
Error: The file “non-existing-file.swift” couldn’t be opened because
8181
there is no such file.
8282
```
83+
84+
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.
85+
86+
```swift
87+
struct RuntimeError: Error, CustomStringConvertible {
88+
var description: String
89+
}
90+
91+
struct Example: ParsableCommand {
92+
@Argument() var inputFile: String
93+
94+
func run() throws {
95+
if !ExampleCore.processFile(inputFile) {
96+
// ExampleCore.processFile(_:) prints its own errors
97+
// and returns `false` on failure.
98+
throw ExitCode.failure
99+
}
100+
}
101+
}
102+
```

Examples/math/main.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,34 @@ extension Math.Statistics {
186186

187187
@Argument(help: "A group of floating-point values to operate on.")
188188
var values: [Double]
189+
190+
// These args and the validation method are for testing exit codes:
191+
@Flag(help: .hidden)
192+
var testSuccessExitCode: Bool
193+
@Flag(help: .hidden)
194+
var testFailureExitCode: Bool
195+
@Flag(help: .hidden)
196+
var testValidationExitCode: Bool
197+
@Option(help: .hidden)
198+
var testCustomExitCode: Int32?
199+
200+
func validate() throws {
201+
if testSuccessExitCode {
202+
throw ExitCode.success
203+
}
204+
205+
if testFailureExitCode {
206+
throw ExitCode.failure
207+
}
208+
209+
if testValidationExitCode {
210+
throw ExitCode.validationFailure
211+
}
212+
213+
if let exitCode = testCustomExitCode {
214+
throw ExitCode(exitCode)
215+
}
216+
}
189217
}
190218
}
191219

Sources/ArgumentParser/CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
add_library(ArgumentParser
22
"Parsable Properties/Argument.swift"
33
"Parsable Properties/ArgumentHelp.swift"
4+
"Parsable Properties/Errors.swift"
45
"Parsable Properties/Flag.swift"
56
"Parsable Properties/NameSpecification.swift"
67
"Parsable Properties/Option.swift"
78
"Parsable Properties/OptionGroup.swift"
8-
"Parsable Properties/ValidationError.swift"
99

1010
"Parsable Types/CommandConfiguration.swift"
1111
"Parsable Types/ExpressibleByArgument.swift"

Sources/ArgumentParser/Parsable Properties/ValidationError.swift renamed to Sources/ArgumentParser/Parsable Properties/Errors.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@
99
//
1010
//===----------------------------------------------------------------------===//
1111

12+
#if canImport(Glibc)
13+
import Glibc
14+
#elseif canImport(Darwin)
15+
import Darwin
16+
#elseif canImport(MSVCRT)
17+
import MSVCRT
18+
#endif
19+
1220
/// An error type that is presented to the user as an error with parsing their
1321
/// command-line input.
1422
public struct ValidationError: Error, CustomStringConvertible {
@@ -24,6 +32,29 @@ public struct ValidationError: Error, CustomStringConvertible {
2432
}
2533
}
2634

35+
/// An error type that only includes an exit code.
36+
///
37+
/// If you're printing custom errors messages yourself, you can throw this error
38+
/// to specify the exit code without adding any additional output to standard
39+
/// out or standard error.
40+
public struct ExitCode: Error {
41+
var code: Int32
42+
43+
/// Creates a new `ExitCode` with the given code.
44+
public init(_ code: Int32) {
45+
self.code = code
46+
}
47+
48+
/// An exit code that indicates successful completion of a command.
49+
public static let success = ExitCode(EXIT_SUCCESS)
50+
51+
/// An exit code that indicates that the command failed.
52+
public static let failure = ExitCode(EXIT_FAILURE)
53+
54+
/// An exit code that indicates that the user provided invalid input.
55+
public static let validationFailure = ExitCode(EX_USAGE)
56+
}
57+
2758
/// An error type that represents a clean (i.e. non-error state) exit of the
2859
/// utility.
2960
///

Sources/ArgumentParser/Parsable Types/ParsableArguments.swift

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -139,14 +139,16 @@ extension ParsableArguments {
139139
withError error: Error? = nil
140140
) -> Never {
141141
guard let error = error else {
142-
_exit(EXIT_SUCCESS)
142+
_exit(ExitCode.success.code)
143143
}
144144

145145
let messageInfo = MessageInfo(error: error, type: self)
146-
if messageInfo.shouldExitCleanly {
147-
print(messageInfo.fullText)
148-
} else {
149-
print(messageInfo.fullText, to: &standardError)
146+
if !messageInfo.fullText.isEmpty {
147+
if messageInfo.shouldExitCleanly {
148+
print(messageInfo.fullText)
149+
} else {
150+
print(messageInfo.fullText, to: &standardError)
151+
}
150152
}
151153
_exit(messageInfo.exitCode)
152154
}

Sources/ArgumentParser/Usage/MessageInfo.swift

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
enum MessageInfo {
1515
case help(text: String)
1616
case validation(message: String, usage: String)
17-
case other(message: String)
17+
case other(message: String, exitCode: Int32)
1818

1919
init(error: Error, type: ParsableArguments.Type) {
2020
var commandStack: [ParsableCommand.Type]
@@ -61,16 +61,18 @@ enum MessageInfo {
6161
case .message(let message):
6262
self = .help(text: message)
6363
}
64+
case let error as ExitCode:
65+
self = .other(message: "", exitCode: error.code)
6466
case let error as LocalizedError where error.errorDescription != nil:
65-
self = .other(message: error.errorDescription!)
67+
self = .other(message: error.errorDescription!, exitCode: EXIT_FAILURE)
6668
default:
67-
self = .other(message: String(describing: error))
69+
self = .other(message: String(describing: error), exitCode: EXIT_FAILURE)
6870
}
6971
} else if let parserError = parserError {
7072
let message = ArgumentSet(commandStack.last!).helpMessage(for: parserError)
7173
self = .validation(message: message, usage: usage)
7274
} else {
73-
self = .other(message: String(describing: error))
75+
self = .other(message: String(describing: error), exitCode: EXIT_FAILURE)
7476
}
7577
}
7678

@@ -80,7 +82,7 @@ enum MessageInfo {
8082
return text
8183
case .validation(message: let message, usage: _):
8284
return message
83-
case .other(message: let message):
85+
case .other(let message, _):
8486
return message
8587
}
8688
}
@@ -90,9 +92,10 @@ enum MessageInfo {
9092
case .help(text: let text):
9193
return text
9294
case .validation(message: let message, usage: let usage):
93-
return "Error: \(message)\n\(usage)"
94-
case .other(message: let message):
95-
return "Error: \(message)"
95+
let errorMessage = message.isEmpty ? "" : "Error: \(message)\n"
96+
return errorMessage + usage
97+
case .other(let message, _):
98+
return message.isEmpty ? "" : "Error: \(message)"
9699
}
97100
}
98101

@@ -105,9 +108,9 @@ enum MessageInfo {
105108

106109
var exitCode: Int32 {
107110
switch self {
108-
case .help: return EXIT_SUCCESS
109-
case .validation: return EX_USAGE
110-
case .other: return EXIT_FAILURE
111+
case .help: return ExitCode.success.code
112+
case .validation: return ExitCode.validationFailure.code
113+
case .other(_, let exitCode): return exitCode
111114
}
112115
}
113116
}

Sources/TestHelpers/TestHelpers.swift

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ extension XCTest {
136136
public func AssertExecuteCommand(
137137
command: String,
138138
expected: String? = nil,
139-
shouldError: Bool = false,
139+
exitCode: Int32 = 0,
140140
file: StaticString = #file, line: UInt = #line)
141141
{
142142
let splitCommand = command.split(separator: " ")
@@ -171,10 +171,7 @@ extension XCTest {
171171
if let expected = expected {
172172
AssertEqualStringsIgnoringTrailingWhitespace(expected, errorActual + outputActual, file: file, line: line)
173173
}
174-
if shouldError {
175-
XCTAssertNotEqual(process.terminationStatus, 0, file: file, line: line)
176-
} else {
177-
XCTAssertEqual(process.terminationStatus, 0, file: file, line: line)
178-
}
174+
175+
XCTAssertEqual(process.terminationStatus, exitCode, file: file, line: line)
179176
}
180177
}

Tests/EndToEndTests/ValidationEndToEndTests.swift

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ fileprivate enum UserValidationError: LocalizedError {
2828
}
2929

3030
fileprivate struct Foo: ParsableArguments {
31+
static var usageString: String = """
32+
Usage: foo [--count <count>] [<names> ...] [--version] [--throw]
33+
"""
34+
3135
@Option()
3236
var count: Int?
3337

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

47+
@Flag(help: .hidden)
48+
var showUsageOnly: Bool
49+
50+
@Flag(help: .hidden)
51+
var failValidationSilently: Bool
52+
53+
@Flag(help: .hidden)
54+
var failSilently: Bool
55+
4356
mutating func validate() throws {
4457
if version {
4558
throw CleanExit.message("0.0.1")
@@ -56,6 +69,18 @@ fileprivate struct Foo: ParsableArguments {
5669
if throwCustomError {
5770
throw UserValidationError.userValidationError
5871
}
72+
73+
if showUsageOnly {
74+
throw ValidationError("")
75+
}
76+
77+
if failValidationSilently {
78+
throw ExitCode.validationFailure
79+
}
80+
81+
if failSilently {
82+
throw ExitCode.failure
83+
}
5984
}
6085
}
6186

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

87112
AssertErrorMessage(Foo.self, ["--count", "3", "Joe"], """
88113
Number of names (1) doesn't match count (3).
89114
""")
90115
AssertFullErrorMessage(Foo.self, ["--count", "3", "Joe"], """
91116
Error: Number of names (1) doesn't match count (3).
92-
Usage: foo [--count <count>] [<names> ...] [--version] [--throw]
117+
\(Foo.usageString)
93118
""")
94119
}
95120

96121
func testCustomErrorValidation() {
97122
// verify that error description is printed if avaiable via LocalizedError
98123
AssertErrorMessage(Foo.self, ["--throw", "Joe"], UserValidationError.userValidationError.errorDescription!)
99124
}
125+
126+
func testEmptyErrorValidation() {
127+
AssertErrorMessage(Foo.self, ["--show-usage-only", "Joe"], "")
128+
AssertFullErrorMessage(Foo.self, ["--show-usage-only", "Joe"], Foo.usageString)
129+
AssertFullErrorMessage(Foo.self, ["--fail-validation-silently", "Joe"], "")
130+
AssertFullErrorMessage(Foo.self, ["--fail-silently", "Joe"], "")
131+
}
100132
}

Tests/ExampleTests/MathExampleTests.swift

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,24 +105,43 @@ final class MathExampleTests: XCTestCase {
105105
Error: Please provide at least one value to calculate the mode.
106106
Usage: math stats average [--kind <kind>] [<values> ...]
107107
""",
108-
shouldError: true)
108+
exitCode: EX_USAGE)
109109
}
110110

111+
func testMath_ExitCodes() throws {
112+
AssertExecuteCommand(
113+
command: "math stats quantiles --test-success-exit-code",
114+
expected: "",
115+
exitCode: EXIT_SUCCESS)
116+
AssertExecuteCommand(
117+
command: "math stats quantiles --test-failure-exit-code",
118+
expected: "",
119+
exitCode: EXIT_FAILURE)
120+
AssertExecuteCommand(
121+
command: "math stats quantiles --test-validation-exit-code",
122+
expected: "",
123+
exitCode: EX_USAGE)
124+
AssertExecuteCommand(
125+
command: "math stats quantiles --test-custom-exit-code 42",
126+
expected: "",
127+
exitCode: 42)
128+
}
129+
111130
func testMath_Fail() throws {
112131
AssertExecuteCommand(
113132
command: "math --foo",
114133
expected: """
115134
Error: Unknown option '--foo'
116135
Usage: math add [--hex-output] [<values> ...]
117136
""",
118-
shouldError: true)
137+
exitCode: EX_USAGE)
119138

120139
AssertExecuteCommand(
121140
command: "math ZZZ",
122141
expected: """
123142
Error: The value 'ZZZ' is invalid for '<values>'
124143
Usage: math add [--hex-output] [<values> ...]
125144
""",
126-
shouldError: true)
145+
exitCode: EX_USAGE)
127146
}
128147
}

Tests/ExampleTests/RepeatExampleTests.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,22 +48,22 @@ final class RepeatExampleTests: XCTestCase {
4848
Error: Missing expected argument '<phrase>'
4949
Usage: repeat [--count <count>] [--include-counter] <phrase>
5050
""",
51-
shouldError: true)
51+
exitCode: EX_USAGE)
5252

5353
AssertExecuteCommand(
5454
command: "repeat hello --count",
5555
expected: """
5656
Error: Missing value for '--count <count>'
5757
Usage: repeat [--count <count>] [--include-counter] <phrase>
5858
""",
59-
shouldError: true)
59+
exitCode: EX_USAGE)
6060

6161
AssertExecuteCommand(
6262
command: "repeat hello --count ZZZ",
6363
expected: """
6464
Error: The value 'ZZZ' is invalid for '--count <count>'
6565
Usage: repeat [--count <count>] [--include-counter] <phrase>
6666
""",
67-
shouldError: true)
67+
exitCode: EX_USAGE)
6868
}
6969
}

0 commit comments

Comments
 (0)