Skip to content

Commit b80fb05

Browse files
authored
Add API for titling an option group (#492)
This change lets you provide a title for option groups, which is used when generating the help screen. Titled option groups, when they exist, are placed between the ARGUMENTS and OPTIONS section of the help. Multiple option groups with the same title are coalesced into a single group. For example, this command declaration: struct Extras: ParsableArguments { @Flag(help: "Print extra output while processing.") var verbose: Bool = false @Flag(help: "Include details no one asked for.") var oversharing: Bool = false } @main struct Example: ParsableCommand { @OptionGroup(title: "Extras") var extras: Extras @argument var name: String? @option var title: String? } yields this help screen: USAGE: example [--verbose] [--oversharing] [<name>] [--title <title>] ARGUMENTS: <name> EXTRAS: --verbose Print extra output while processing. --oversharing Include details no one asked for. OPTIONS: --title <title> -h, --help Show help information.
1 parent d88d4de commit b80fb05

File tree

11 files changed

+588
-7
lines changed

11 files changed

+588
-7
lines changed

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

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ OPTIONS:
7474
-h, --help Show help information.
7575
```
7676

77-
### Controlling Argument Visibility
77+
## Controlling Argument Visibility
7878

7979
You can specify the visibility of any argument, option, or flag.
8080

@@ -141,3 +141,56 @@ OPTIONS:
141141
Use advanced security. (experimental)
142142
-h, --help Show help information.
143143
```
144+
145+
## Grouping Arguments in the Help Screen
146+
147+
When you provide a title in an `@OptionGroup` declaration, that type's
148+
properties are grouped together under your title in the help screen.
149+
For example, this command bundles similar arguments together under a
150+
"Build Options" title:
151+
152+
```swift
153+
struct BuildOptions: ParsableArguments {
154+
@Option(help: "A setting to pass to the compiler.")
155+
var compilerSetting: [String] = []
156+
157+
@Option(help: "A setting to pass to the linker.")
158+
var linkerSetting: [String] = []
159+
}
160+
161+
struct Example: ParsableCommand {
162+
@Argument(help: "The input file to process.")
163+
var inputFile: String
164+
165+
@Flag(help: "Show extra output.")
166+
var verbose: Bool = false
167+
168+
@Option(help: "The path to a configuration file.")
169+
var configFile: String?
170+
171+
@OptionGroup(title: "Build Options")
172+
var buildOptions: BuildOptions
173+
}
174+
```
175+
176+
This grouping is reflected in the command's help screen:
177+
178+
```
179+
% example --help
180+
USAGE: example <input-file> [--verbose] [--config-file <config-file>] [--compiler-setting <compiler-setting> ...] [--linker-setting <linker-setting> ...]
181+
182+
ARGUMENTS:
183+
<input-file> The input file to process.
184+
185+
BUILD OPTIONS:
186+
--compiler-setting <compiler-setting>
187+
A setting to pass to the compiler.
188+
--linker-setting <linker-setting>
189+
A setting to pass to the linker.
190+
191+
OPTIONS:
192+
--verbose Show extra output.
193+
--config-file <config-file>
194+
The path to a configuration file.
195+
-h, --help Show help information.
196+
```

Sources/ArgumentParser/Parsable Properties/OptionGroup.swift

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ public struct OptionGroup<Value: ParsableArguments>: Decodable, ParsedWrapper {
3636
// FIXME: Adding this property works around the crasher described in
3737
// https://github.com/apple/swift-argument-parser/issues/338
3838
internal var _dummy: Bool = false
39+
40+
/// The title to use in the help screen for this option group.
41+
public var title: String = ""
3942

4043
internal init(_parsedValue: Parsed<Value>) {
4144
self._parsedValue = _parsedValue
@@ -62,12 +65,27 @@ public struct OptionGroup<Value: ParsableArguments>: Decodable, ParsedWrapper {
6265
}
6366

6467
/// Creates a property that represents another parsable type, using the
65-
/// specified visibility.
66-
public init(visibility: ArgumentVisibility = .default) {
68+
/// specified title and visibility.
69+
///
70+
/// - Parameters:
71+
/// - title: A title for grouping this option group's members in your
72+
/// command's help screen. If `title` is empty, the members will be
73+
/// displayed alongside the other arguments, flags, and options declared
74+
/// by your command.
75+
/// - visibility: The visibility to use for the entire option group.
76+
public init(
77+
title: String = "",
78+
visibility: ArgumentVisibility = .default
79+
) {
6780
self.init(_parsedValue: .init { parentKey in
68-
return ArgumentSet(Value.self, visibility: .private, parent: .key(parentKey))
81+
var args = ArgumentSet(Value.self, visibility: .private, parent: .key(parentKey))
82+
args.content.withEach {
83+
$0.help.parentTitle = title
84+
}
85+
return args
6986
})
7087
self._visibility = visibility
88+
self.title = title
7189
}
7290

7391
/// The value presented by this property wrapper.
@@ -111,3 +129,15 @@ extension OptionGroup {
111129
self.init(visibility: .default)
112130
}
113131
}
132+
133+
// MARK: Deprecated
134+
135+
extension OptionGroup {
136+
@_disfavoredOverload
137+
@available(*, deprecated, renamed: "init(title:visibility:)")
138+
public init(
139+
visibility _visibility: ArgumentVisibility = .default
140+
) {
141+
self.init(title: "", visibility: _visibility)
142+
}
143+
}

Sources/ArgumentParser/Parsing/ArgumentDefinition.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ struct ArgumentDefinition {
5454
var discussion: String
5555
var valueName: String
5656
var visibility: ArgumentVisibility
57+
var parentTitle: String
5758

5859
init(
5960
allValues: [String],
@@ -72,6 +73,7 @@ struct ArgumentDefinition {
7273
self.discussion = help?.discussion ?? ""
7374
self.valueName = help?.valueName ?? ""
7475
self.visibility = help?.visibility ?? .default
76+
self.parentTitle = ""
7577
}
7678
}
7779

Sources/ArgumentParser/Usage/DumpHelpGenerator.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ fileprivate extension ArgumentInfoV0 {
126126
self.init(
127127
kind: kind,
128128
shouldDisplay: argument.help.visibility.base == .default,
129+
sectionTitle: argument.help.parentTitle.nonEmpty,
129130
isOptional: argument.help.options.contains(.isOptional),
130131
isRepeating: argument.help.options.contains(.isRepeating),
131132
names: argument.names.map(ArgumentInfoV0.NameInfoV0.init),

Sources/ArgumentParser/Usage/HelpGenerator.swift

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ internal struct HelpGenerator {
5151
case positionalArguments
5252
case subcommands
5353
case options
54+
case title(String)
5455

5556
var description: String {
5657
switch self {
@@ -60,6 +61,8 @@ internal struct HelpGenerator {
6061
return "Subcommands"
6162
case .options:
6263
return "Options"
64+
case .title(let name):
65+
return name
6366
}
6467
}
6568
}
@@ -136,6 +139,10 @@ internal struct HelpGenerator {
136139

137140
var positionalElements: [Section.Element] = []
138141
var optionElements: [Section.Element] = []
142+
143+
// Simulate an ordered dictionary using a dictionary and array for ordering.
144+
var titledSections: [String: [Section.Element]] = [:]
145+
var sectionTitles: [String] = []
139146

140147
/// Start with a full slice of the ArgumentSet so we can peel off one or
141148
/// more elements at a time.
@@ -183,9 +190,15 @@ internal struct HelpGenerator {
183190
}
184191

185192
let element = Section.Element(label: synopsis, abstract: description, discussion: arg.help.discussion)
186-
if case .positional = arg.kind {
193+
switch (arg.kind, arg.help.parentTitle) {
194+
case (_, let sectionTitle) where !sectionTitle.isEmpty:
195+
if !titledSections.keys.contains(sectionTitle) {
196+
sectionTitles.append(sectionTitle)
197+
}
198+
titledSections[sectionTitle, default: []].append(element)
199+
case (.positional, _):
187200
positionalElements.append(element)
188-
} else {
201+
default:
189202
optionElements.append(element)
190203
}
191204
}
@@ -203,8 +216,16 @@ internal struct HelpGenerator {
203216
abstract: command.configuration.abstract)
204217
}
205218

219+
// Combine the compiled groups in this order:
220+
// - arguments
221+
// - named sections
222+
// - options/flags
223+
// - subcommands
206224
return [
207225
Section(header: .positionalArguments, elements: positionalElements),
226+
] + sectionTitles.map { name in
227+
Section(header: .title(name), elements: titledSections[name, default: []])
228+
} + [
208229
Section(header: .options, elements: optionElements),
209230
Section(header: .subcommands, elements: subcommandElements),
210231
]

Sources/ArgumentParser/Utilities/CollectionExtensions.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,13 @@ extension Collection {
1414
isEmpty ? replacement() : self
1515
}
1616
}
17+
18+
extension MutableCollection {
19+
mutating func withEach(_ body: (inout Element) throws -> Void) rethrows {
20+
var i = startIndex
21+
while i < endIndex {
22+
try body(&self[i])
23+
formIndex(after: &i)
24+
}
25+
}
26+
}

Sources/ArgumentParser/Utilities/StringExtensions.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,6 @@ extension StringProtocol where SubSequence == Substring {
132132
/// // 3
133133
/// "bar".editDistance(to: "baz")
134134
/// // 1
135-
136135
func editDistance(to target: String) -> Int {
137136
let rows = self.count
138137
let columns = target.count
@@ -239,4 +238,8 @@ extension StringProtocol where SubSequence == Substring {
239238
guard lines.count == 2 else { return lines.joined(separator: "") }
240239
return "\(lines[0])\n\(lines[1].indentingEachLine(by: n))"
241240
}
241+
242+
var nonEmpty: Self? {
243+
isEmpty ? nil : self
244+
}
242245
}

Sources/ArgumentParserToolInfo/ToolInfo.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ public struct ArgumentInfoV0: Codable, Hashable {
122122

123123
/// Argument should appear in help displays.
124124
public var shouldDisplay: Bool
125+
/// Custom name of argument's section.
126+
public var sectionTitle: String?
127+
125128
/// Argument can be omitted.
126129
public var isOptional: Bool
127130
/// Argument can be specified multiple times.
@@ -147,6 +150,7 @@ public struct ArgumentInfoV0: Codable, Hashable {
147150
public init(
148151
kind: KindV0,
149152
shouldDisplay: Bool,
153+
sectionTitle: String?,
150154
isOptional: Bool,
151155
isRepeating: Bool,
152156
names: [NameInfoV0]?,
@@ -160,6 +164,8 @@ public struct ArgumentInfoV0: Codable, Hashable {
160164
self.kind = kind
161165

162166
self.shouldDisplay = shouldDisplay
167+
self.sectionTitle = sectionTitle
168+
163169
self.isOptional = isOptional
164170
self.isRepeating = isRepeating
165171

Tests/ArgumentParserUnitTests/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ add_library(UnitTests
44
HelpGenerationTests.swift
55
HelpGenerationTests+AtArgument.swift
66
HelpGenerationTests+AtOption.swift
7+
HelpGenerationTests+GroupName.swift
78
NameSpecificationTests.swift
89
SplitArgumentTests.swift
910
StringSnakeCaseTests.swift

Tests/ArgumentParserUnitTests/DumpHelpGenerationTests.swift

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,24 @@ extension DumpHelpGenerationTests {
5050
var argWithDefaultValue: Int = 1
5151
}
5252

53+
struct Options: ParsableArguments {
54+
@Flag var verbose = false
55+
@Option var name: String
56+
}
57+
58+
struct B: ParsableCommand {
59+
@OptionGroup(title: "Other")
60+
var options: Options
61+
}
62+
5363
public func testDumpA() throws {
5464
try AssertDump(for: A.self, equals: Self.aDumpText)
5565
}
5666

67+
public func testDumpB() throws {
68+
try AssertDump(for: B.self, equals: Self.bDumpText)
69+
}
70+
5771
public func testDumpExampleCommands() throws {
5872
struct TestCase {
5973
let expected: String
@@ -233,6 +247,75 @@ extension DumpHelpGenerationTests {
233247
}
234248
"""
235249

250+
static let bDumpText: String = """
251+
{
252+
"command" : {
253+
"arguments" : [
254+
{
255+
"isOptional" : true,
256+
"isRepeating" : false,
257+
"kind" : "flag",
258+
"names" : [
259+
{
260+
"kind" : "long",
261+
"name" : "verbose"
262+
}
263+
],
264+
"preferredName" : {
265+
"kind" : "long",
266+
"name" : "verbose"
267+
},
268+
"sectionTitle" : "Other",
269+
"shouldDisplay" : true,
270+
"valueName" : "verbose"
271+
},
272+
{
273+
"isOptional" : false,
274+
"isRepeating" : false,
275+
"kind" : "option",
276+
"names" : [
277+
{
278+
"kind" : "long",
279+
"name" : "name"
280+
}
281+
],
282+
"preferredName" : {
283+
"kind" : "long",
284+
"name" : "name"
285+
},
286+
"sectionTitle" : "Other",
287+
"shouldDisplay" : true,
288+
"valueName" : "name"
289+
},
290+
{
291+
"abstract" : "Show help information.",
292+
"isOptional" : true,
293+
"isRepeating" : false,
294+
"kind" : "flag",
295+
"names" : [
296+
{
297+
"kind" : "short",
298+
"name" : "h"
299+
},
300+
{
301+
"kind" : "long",
302+
"name" : "help"
303+
}
304+
],
305+
"preferredName" : {
306+
"kind" : "long",
307+
"name" : "help"
308+
},
309+
"shouldDisplay" : true,
310+
"valueName" : "help"
311+
}
312+
],
313+
"commandName" : "b"
314+
},
315+
"serializationVersion" : 0
316+
}
317+
"""
318+
236319
static let mathDumpText: String = """
237320
{
238321
"command" : {

0 commit comments

Comments
 (0)