Skip to content

Commit 5540737

Browse files
authored
Add customization point for command usage text (#400)
1 parent 5772794 commit 5540737

File tree

8 files changed

+218
-25
lines changed

8 files changed

+218
-25
lines changed

Sources/ArgumentParser/Documentation.docc/Articles/CustomizingHelp.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,21 +76,30 @@ OPTIONS:
7676

7777
## Customizing Help for Commands
7878

79-
In addition to configuring the command name and subcommands, as described in <doc:CommandsAndSubcommands>, you can also configure a command's help text by providing an abstract and discussion.
79+
In addition to configuring the command name and subcommands, as described in <doc:CommandsAndSubcommands>, you can also configure a command's help text by providing an abstract, discussion, or custom usage string.
8080

8181
```swift
8282
struct Repeat: ParsableCommand {
8383
static var configuration = CommandConfiguration(
8484
abstract: "Repeats your input phrase.",
85+
usage: """
86+
repeat <phrase>
87+
repeat --count <count> <phrase>
88+
""",
8589
discussion: """
8690
Prints to stdout forever, or until you halt the program.
8791
""")
8892

8993
@Argument(help: "The phrase to repeat.")
9094
var phrase: String
9195

96+
@Option(help: "How many times to repeat.")
97+
var count: Int?
98+
9299
mutating func run() throws {
93-
while true { print(phrase) }
100+
for _ in 0..<(count ?? Int.max) {
101+
print(phrase)
102+
}
94103
}
95104
}
96105
```
@@ -104,6 +113,7 @@ OVERVIEW: Repeats your input phrase.
104113
Prints to stdout forever, or until you halt the program.
105114
106115
USAGE: repeat <phrase>
116+
repeat --count <count> <phrase>
107117
108118
ARGUMENTS:
109119
<phrase> The phrase to repeat.

Sources/ArgumentParser/Parsable Types/CommandConfiguration.swift

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ public struct CommandConfiguration {
2727
/// A one-line description of this command.
2828
public var abstract: String
2929

30+
/// A customized usage string to be shown in the help display and error
31+
/// messages.
32+
///
33+
/// If `usage` is `nil`, the help display and errors show the autogenerated
34+
/// usage string. To hide the usage string entirely, set `usage` to the empty
35+
/// string.
36+
public var usage: String?
37+
3038
/// A longer description of this command, to be shown in the extended help
3139
/// display.
3240
public var discussion: String
@@ -54,6 +62,10 @@ public struct CommandConfiguration {
5462
/// `commandName` is `nil`, the command name is derived by converting
5563
/// the name of the command type to hyphen-separated lowercase words.
5664
/// - abstract: A one-line description of the command.
65+
/// - usage: A custom usage description for the command. When you provide
66+
/// a non-`nil` string, the argument parser uses `usage` instead of
67+
/// automatically generating a usage description. Passing an empty string
68+
/// hides the usage string altogether.
5769
/// - discussion: A longer description of the command.
5870
/// - version: The version number for this command. When you provide a
5971
/// non-empty string, the argument parser prints it if the user provides
@@ -67,10 +79,11 @@ public struct CommandConfiguration {
6779
/// - helpNames: The flag names to use for requesting help, when combined
6880
/// with a simulated Boolean property named `help`. If `helpNames` is
6981
/// `nil`, the names are inherited from the parent command, if any, or
70-
/// `-h` and `--help`.
82+
/// are `-h` and `--help`.
7183
public init(
7284
commandName: String? = nil,
7385
abstract: String = "",
86+
usage: String? = nil,
7487
discussion: String = "",
7588
version: String = "",
7689
shouldDisplay: Bool = true,
@@ -80,6 +93,7 @@ public struct CommandConfiguration {
8093
) {
8194
self.commandName = commandName
8295
self.abstract = abstract
96+
self.usage = usage
8397
self.discussion = discussion
8498
self.version = version
8599
self.shouldDisplay = shouldDisplay
@@ -94,6 +108,7 @@ public struct CommandConfiguration {
94108
commandName: String? = nil,
95109
_superCommandName: String,
96110
abstract: String = "",
111+
usage: String? = nil,
97112
discussion: String = "",
98113
version: String = "",
99114
shouldDisplay: Bool = true,
@@ -104,6 +119,7 @@ public struct CommandConfiguration {
104119
self.commandName = commandName
105120
self._superCommandName = _superCommandName
106121
self.abstract = abstract
122+
self.usage = usage
107123
self.discussion = discussion
108124
self.version = version
109125
self.shouldDisplay = shouldDisplay
@@ -112,3 +128,28 @@ public struct CommandConfiguration {
112128
self.helpNames = helpNames
113129
}
114130
}
131+
132+
extension CommandConfiguration {
133+
@available(*, deprecated, message: "Use the memberwise initializer with the usage parameter.")
134+
public init(
135+
commandName: String?,
136+
abstract: String,
137+
discussion: String,
138+
version: String,
139+
shouldDisplay: Bool,
140+
subcommands: [ParsableCommand.Type],
141+
defaultSubcommand: ParsableCommand.Type?,
142+
helpNames: NameSpecification?
143+
) {
144+
self.init(
145+
commandName: commandName,
146+
abstract: abstract,
147+
usage: "",
148+
discussion: discussion,
149+
version: version,
150+
shouldDisplay: shouldDisplay,
151+
subcommands: subcommands,
152+
defaultSubcommand: defaultSubcommand,
153+
helpNames: helpNames)
154+
}
155+
}

Sources/ArgumentParser/Usage/HelpGenerator.swift

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,15 @@ internal struct HelpGenerator {
103103
toolName = "\(superName) \(toolName)"
104104
}
105105

106-
var usage = UsageGenerator(toolName: toolName, definition: [currentArgSet]).synopsis
107-
if !currentCommand.configuration.subcommands.isEmpty {
108-
if usage.last != " " { usage += " " }
109-
usage += "<subcommand>"
106+
if let usage = currentCommand.configuration.usage {
107+
self.usage = usage
108+
} else {
109+
var usage = UsageGenerator(toolName: toolName, definition: [currentArgSet]).synopsis
110+
if !currentCommand.configuration.subcommands.isEmpty {
111+
if usage.last != " " { usage += " " }
112+
usage += "<subcommand>"
113+
}
114+
self.usage = usage
110115
}
111116

112117
self.abstract = currentCommand.configuration.abstract
@@ -117,7 +122,6 @@ internal struct HelpGenerator {
117122
self.abstract += "\n\(currentCommand.configuration.discussion)"
118123
}
119124

120-
self.usage = usage
121125
self.sections = HelpGenerator.generateSections(commandStack: commandStack)
122126
self.discussionSections = []
123127
}
@@ -210,7 +214,8 @@ internal struct HelpGenerator {
210214
}
211215

212216
func usageMessage() -> String {
213-
return "Usage: \(usage)"
217+
guard !usage.isEmpty else { return "" }
218+
return "Usage: \(usage.hangingIndentingEachLine(by: 7))"
214219
}
215220

216221
var includesSubcommands: Bool {
@@ -243,10 +248,13 @@ internal struct HelpGenerator {
243248
"""
244249
}
245250

251+
let renderedUsage = usage.isEmpty
252+
? ""
253+
: "USAGE: \(usage.hangingIndentingEachLine(by: 7))\n\n"
254+
246255
return """
247256
\(renderedAbstract)\
248-
USAGE: \(usage)
249-
257+
\(renderedUsage)\
250258
\(renderedSections)\(helpSubcommandMessage)
251259
"""
252260
}

Sources/ArgumentParser/Usage/MessageInfo.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,10 @@ enum MessageInfo {
7878

7979
let commandNames = commandStack.map { $0._commandName }.joined(separator: " ")
8080
if let helpName = commandStack.getPrimaryHelpName() {
81-
usage += "\n See '\(commandNames) \(helpName.synopsisString)' for more information."
81+
if !usage.isEmpty {
82+
usage += "\n"
83+
}
84+
usage += " See '\(commandNames) \(helpName.synopsisString)' for more information."
8285
}
8386

8487
// Parsing errors and user-thrown validation errors have the usage

Sources/ArgumentParser/Utilities/StringExtensions.swift

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
//
1010
//===----------------------------------------------------------------------===//
1111

12-
extension String {
12+
extension StringProtocol where SubSequence == Substring {
1313
func wrapped(to columns: Int, wrappingIndent: Int = 0) -> String {
1414
let columns = columns - wrappingIndent
1515
guard columns > 0 else {
@@ -96,7 +96,7 @@ extension String {
9696
/// "myURLProperty".convertedToSnakeCase(separator: "-")
9797
/// // my-url-property
9898
func convertedToSnakeCase(separator: Character = "_") -> String {
99-
guard !isEmpty else { return self }
99+
guard !isEmpty else { return "" }
100100
var result = ""
101101
// Whether we should append a separator when we see a uppercase character.
102102
var separateOnUppercase = true
@@ -138,7 +138,7 @@ extension String {
138138
let columns = target.count
139139

140140
if rows <= 0 || columns <= 0 {
141-
return max(rows, columns)
141+
return Swift.max(rows, columns)
142142
}
143143

144144
var matrix = Array(repeating: Array(repeating: 0, count: columns + 1), count: rows + 1)
@@ -168,14 +168,19 @@ extension String {
168168
}
169169

170170
func indentingEachLine(by n: Int) -> String {
171-
let hasTrailingNewline = self.last == "\n"
172171
let lines = self.split(separator: "\n", omittingEmptySubsequences: false)
173-
if hasTrailingNewline && lines.last == "" {
174-
return lines.dropLast().map { String(repeating: " ", count: n) + $0 }
175-
.joined(separator: "\n") + "\n"
176-
} else {
177-
return lines.map { String(repeating: " ", count: n) + $0 }
178-
.joined(separator: "\n")
179-
}
172+
let spacer = String(repeating: " ", count: n)
173+
return lines.map {
174+
$0.isEmpty ? $0 : spacer + $0
175+
}.joined(separator: "\n")
176+
}
177+
178+
func hangingIndentingEachLine(by n: Int) -> String {
179+
let lines = self.split(
180+
separator: "\n",
181+
maxSplits: 1,
182+
omittingEmptySubsequences: false)
183+
guard lines.count == 2 else { return lines.joined(separator: "") }
184+
return "\(lines[0])\n\(lines[1].indentingEachLine(by: n))"
180185
}
181186
}

Tests/ArgumentParserUnitTests/CompletionScriptTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ _base() {
183183
fi
184184
case $prev in
185185
--name)
186-
186+
187187
return
188188
;;
189189
--kind)

Tests/ArgumentParserUnitTests/HelpGenerationTests.swift

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -595,7 +595,6 @@ extension HelpGenerationTests {
595595
}
596596

597597
func testIssue278() {
598-
print(ParserBug.helpMessage(for: ParserBug.Sub.self))
599598
AssertHelp(for: ParserBug.Sub.self, root: ParserBug.self, equals: """
600599
USAGE: parserBug sub [--example] [<argument>]
601600
@@ -608,4 +607,94 @@ extension HelpGenerationTests {
608607
609608
""")
610609
}
610+
611+
struct CustomUsageShort: ParsableCommand {
612+
static var configuration: CommandConfiguration {
613+
CommandConfiguration(usage: """
614+
example [--verbose] <file-name>
615+
""")
616+
}
617+
618+
@Argument var file: String
619+
@Flag var verboseMode = false
620+
}
621+
622+
struct CustomUsageLong: ParsableCommand {
623+
static var configuration: CommandConfiguration {
624+
CommandConfiguration(usage: """
625+
example <file-name>
626+
example --verbose <file-name>
627+
example --help
628+
""")
629+
}
630+
631+
@Argument var file: String
632+
@Flag var verboseMode = false
633+
}
634+
635+
struct CustomUsageHidden: ParsableCommand {
636+
static var configuration: CommandConfiguration {
637+
CommandConfiguration(usage: "")
638+
}
639+
640+
@Argument var file: String
641+
@Flag var verboseMode = false
642+
}
643+
644+
func testCustomUsageHelp() {
645+
XCTAssertEqual(CustomUsageShort.helpMessage(columns: 80), """
646+
USAGE: example [--verbose] <file-name>
647+
648+
ARGUMENTS:
649+
<file>
650+
651+
OPTIONS:
652+
--verbose-mode
653+
-h, --help Show help information.
654+
655+
""")
656+
657+
XCTAssertEqual(CustomUsageLong.helpMessage(columns: 80), """
658+
USAGE: example <file-name>
659+
example --verbose <file-name>
660+
example --help
661+
662+
ARGUMENTS:
663+
<file>
664+
665+
OPTIONS:
666+
--verbose-mode
667+
-h, --help Show help information.
668+
669+
""")
670+
671+
XCTAssertEqual(CustomUsageHidden.helpMessage(columns: 80), """
672+
ARGUMENTS:
673+
<file>
674+
675+
OPTIONS:
676+
--verbose-mode
677+
-h, --help Show help information.
678+
679+
""")
680+
}
681+
682+
func testCustomUsageError() {
683+
XCTAssertEqual(CustomUsageShort.fullMessage(for: ValidationError("Test")), """
684+
Error: Test
685+
Usage: example [--verbose] <file-name>
686+
See 'custom-usage-short --help' for more information.
687+
""")
688+
XCTAssertEqual(CustomUsageLong.fullMessage(for: ValidationError("Test")), """
689+
Error: Test
690+
Usage: example <file-name>
691+
example --verbose <file-name>
692+
example --help
693+
See 'custom-usage-long --help' for more information.
694+
""")
695+
XCTAssertEqual(CustomUsageHidden.fullMessage(for: ValidationError("Test")), """
696+
Error: Test
697+
See 'custom-usage-hidden --help' for more information.
698+
""")
699+
}
611700
}

0 commit comments

Comments
 (0)