Skip to content

Commit b013355

Browse files
committed
Introduce subcommand grouping into the command configuration to improve help
Add optional support for grouping subcommands into named groups, to help bring order to commands with many subcommands without requiring additional structure. For example, here's the help for a "subgroupings" command that has an ungrouped subcommand (m), and two groups of subcommands ("broken" and "complicated"). USAGE: subgroupings <subcommand> OPTIONS: -h, --help Show help information. SUBCOMMANDS: m BROKEN SUBCOMMANDS: foo Perform some foo bar Perform bar operations COMPLICATED SUBCOMMANDS: n See 'subgroupings help <subcommand>' for detailed help. To be able freely mix subcommands and subcommand groups, CommandConfiguration has a new initializer that takes a result builder. The help output above is created like this: struct WithSubgroups: ParsableCommand { static let configuration = CommandConfiguration( commandName: "subgroupings" ) { CommandGroup(name: "Broken") { Foo.self Bar.self } M.self CommandGroup(name: "Complicated") { N.self } } } Each `CommandGroup` names a new group and is given commands (there are no groups within groups). The other entries are arbitrary ParsableCommands. This structure is only cosmetic, and only affects help generation by providing more structure for the reader. It doesn't impact existing clients, who can still reason about the flattened list of subcommands if they prefer.
1 parent 0fbc884 commit b013355

File tree

5 files changed

+254
-23
lines changed

5 files changed

+254
-23
lines changed

Sources/ArgumentParser/Parsable Types/CommandConfiguration.swift

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,22 @@ public struct CommandConfiguration: Sendable {
4747
public var shouldDisplay: Bool
4848

4949
/// An array of the types that define subcommands for this command.
50-
public var subcommands: [ParsableCommand.Type]
51-
50+
///
51+
/// This property "flattens" the grouping structure of the subcommands.
52+
/// Use 'groupedSubcommands' to retain the grouping structure.
53+
public var subcommands: [ParsableCommand.Type] {
54+
get {
55+
groupedSubcommands.flatMap { $0.flattenedSubcommands }
56+
}
57+
58+
set {
59+
groupedSubcommands = newValue.map { Subcommand.single($0) }
60+
}
61+
}
62+
63+
/// The list of subcommands and subcommand groups.
64+
public var groupedSubcommands: [Subcommand]
65+
5266
/// The default command type to run if no subcommand is given.
5367
public var defaultSubcommand: ParsableCommand.Type?
5468

@@ -62,6 +76,58 @@ public struct CommandConfiguration: Sendable {
6276
/// or `commandName` itself if provided.
6377
public var aliases: [String]
6478

79+
/// Creates the configuration for a command.
80+
///
81+
/// - Parameters:
82+
/// - commandName: The name of the command to use on the command line. If
83+
/// `commandName` is `nil`, the command name is derived by converting
84+
/// the name of the command type to hyphen-separated lowercase words.
85+
/// - abstract: A one-line description of the command.
86+
/// - usage: A custom usage description for the command. When you provide
87+
/// a non-`nil` string, the argument parser uses `usage` instead of
88+
/// automatically generating a usage description. Passing an empty string
89+
/// hides the usage string altogether.
90+
/// - discussion: A longer description of the command.
91+
/// - version: The version number for this command. When you provide a
92+
/// non-empty string, the argument parser prints it if the user provides
93+
/// a `--version` flag.
94+
/// - shouldDisplay: A Boolean value indicating whether the command
95+
/// should be shown in the extended help display.
96+
/// - subcommands: An array of the types that define subcommands for the
97+
/// command.
98+
/// - defaultSubcommand: The default command type to run if no subcommand
99+
/// is given.
100+
/// - helpNames: The flag names to use for requesting help, when combined
101+
/// with a simulated Boolean property named `help`. If `helpNames` is
102+
/// `nil`, the names are inherited from the parent command, if any, or
103+
/// are `-h` and `--help`.
104+
/// - aliases: An array of aliases for the command's name. All of the aliases
105+
/// MUST not match the actual command name, whether that be the derived name
106+
/// if `commandName` is not provided, or `commandName` itself if provided.
107+
public init(
108+
commandName: String? = nil,
109+
abstract: String = "",
110+
usage: String? = nil,
111+
discussion: String = "",
112+
version: String = "",
113+
shouldDisplay: Bool = true,
114+
defaultSubcommand: ParsableCommand.Type? = nil,
115+
helpNames: NameSpecification? = nil,
116+
aliases: [String] = [],
117+
@SubcommandsBuilder groupedSubcommands: () -> [Subcommand]
118+
) {
119+
self.commandName = commandName
120+
self.abstract = abstract
121+
self.usage = usage
122+
self.discussion = discussion
123+
self.version = version
124+
self.shouldDisplay = shouldDisplay
125+
self.groupedSubcommands = groupedSubcommands()
126+
self.defaultSubcommand = defaultSubcommand
127+
self.helpNames = helpNames
128+
self.aliases = aliases
129+
}
130+
65131
/// Creates the configuration for a command.
66132
///
67133
/// - Parameters:
@@ -108,7 +174,7 @@ public struct CommandConfiguration: Sendable {
108174
self.discussion = discussion
109175
self.version = version
110176
self.shouldDisplay = shouldDisplay
111-
self.subcommands = subcommands
177+
self.groupedSubcommands = subcommands.map { .single($0) }
112178
self.defaultSubcommand = defaultSubcommand
113179
self.helpNames = helpNames
114180
self.aliases = aliases
@@ -136,7 +202,7 @@ public struct CommandConfiguration: Sendable {
136202
self.discussion = discussion
137203
self.version = version
138204
self.shouldDisplay = shouldDisplay
139-
self.subcommands = subcommands
205+
self.groupedSubcommands = subcommands.map { .single($0) }
140206
self.defaultSubcommand = defaultSubcommand
141207
self.helpNames = helpNames
142208
self.aliases = aliases
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
//===----------------------------------------------------------*- swift -*-===//
2+
//
3+
// This source file is part of the Swift Argument Parser open source project
4+
//
5+
// Copyright (c) 2020 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
//
10+
//===----------------------------------------------------------------------===//
11+
12+
/// A set of commands grouped together under a common name.
13+
public struct CommandGroup: Sendable {
14+
/// The name of the command group that will be displayed in help.
15+
public let name: String
16+
17+
/// The list of subcommands that are part of this group.
18+
public let subcommands: [ParsableCommand.Type]
19+
20+
/// Create a command group.
21+
public init(
22+
name: String,
23+
@CommandsBuilder subcommands: () -> [ParsableCommand.Type]
24+
) {
25+
self.name = name
26+
self.subcommands = subcommands()
27+
}
28+
}
29+
30+
/// Result builder that forms a list of commands.
31+
@resultBuilder
32+
public struct CommandsBuilder {
33+
public static func buildBlock(_ commands: ParsableCommand.Type...) -> [ParsableCommand.Type] {
34+
return commands
35+
}
36+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
//===----------------------------------------------------------*- swift -*-===//
2+
//
3+
// This source file is part of the Swift Argument Parser open source project
4+
//
5+
// Copyright (c) 2020 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
//
10+
//===----------------------------------------------------------------------===//
11+
12+
/// Build a set of subcommands which can potentially be grouped.
13+
@resultBuilder
14+
public struct SubcommandsBuilder {
15+
public static func buildExpression(_ single: ParsableCommand.Type) -> Subcommand {
16+
return .single(single)
17+
}
18+
19+
public static func buildExpression(_ group: CommandGroup) -> Subcommand {
20+
return .group(group)
21+
}
22+
23+
public static func buildBlock(_ subcommands: Subcommand...) -> [Subcommand] {
24+
return subcommands
25+
}
26+
}
27+
28+
/// Describes a single subcommand or a group thereof.
29+
public enum Subcommand: Sendable {
30+
case single(ParsableCommand.Type)
31+
case group(CommandGroup)
32+
}
33+
34+
extension Subcommand {
35+
/// Return a flattened list of all of the subcommands in this tree where the
36+
/// group structure has been eliminated.
37+
var flattenedSubcommands: [ParsableCommand.Type] {
38+
switch self {
39+
case .single(let command):
40+
return [command]
41+
case .group(let group):
42+
return group.subcommands
43+
}
44+
}
45+
}

Sources/ArgumentParser/Usage/HelpGenerator.swift

Lines changed: 65 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ internal struct HelpGenerator {
5252
case subcommands
5353
case options
5454
case title(String)
55-
55+
case groupedSubcommands(String)
56+
5657
var description: String {
5758
switch self {
5859
case .positionalArguments:
@@ -63,6 +64,8 @@ internal struct HelpGenerator {
6364
return "Options"
6465
case .title(let name):
6566
return name
67+
case .groupedSubcommands(let name):
68+
return "\(name) Subcommands"
6669
}
6770
}
6871
}
@@ -211,34 +214,73 @@ internal struct HelpGenerator {
211214
}
212215

213216
let configuration = commandStack.last!.configuration
214-
let subcommandElements: [Section.Element] =
215-
configuration.subcommands.compactMap { command in
216-
guard command.configuration.shouldDisplay else { return nil }
217-
var label = command._commandName
218-
for alias in command.configuration.aliases {
219-
label += ", \(alias)"
217+
218+
// Create section for a grouping of subcommands.
219+
func subcommandSection(
220+
header: Section.Header,
221+
subcommands: [ParsableCommand.Type]
222+
) -> Section {
223+
let subcommandElements: [Section.Element] =
224+
subcommands.compactMap { command in
225+
guard command.configuration.shouldDisplay else { return nil }
226+
var label = command._commandName
227+
for alias in command.configuration.aliases {
228+
label += ", \(alias)"
229+
}
230+
if command == configuration.defaultSubcommand {
231+
label += " (default)"
232+
}
233+
return Section.Element(
234+
label: label,
235+
abstract: command.configuration.abstract)
236+
}
237+
238+
return Section(header: header, elements: subcommandElements)
239+
}
240+
241+
// Sections for all of the grouped subcommands.
242+
let groupedSubcommands: [Section] = configuration.groupedSubcommands
243+
.compactMap { subcommand in
244+
switch subcommand {
245+
case .single(_):
246+
return nil
247+
248+
case .group(let group):
249+
return subcommandSection(
250+
header: .groupedSubcommands(group.name),
251+
subcommands: group.subcommands
252+
)
220253
}
221-
if command == configuration.defaultSubcommand {
222-
label += " (default)"
254+
}
255+
256+
// Section for the ungrouped subcommands.
257+
let ungroupedSubcommands: Section = subcommandSection(
258+
header: .subcommands,
259+
subcommands: configuration.groupedSubcommands.compactMap {
260+
switch $0 {
261+
case .single(let command):
262+
return command
263+
264+
case .group(_):
265+
return nil
223266
}
224-
return Section.Element(
225-
label: label,
226-
abstract: command.configuration.abstract)
227-
}
228-
267+
}
268+
)
269+
229270
// Combine the compiled groups in this order:
230271
// - arguments
231272
// - named sections
232273
// - options/flags
233-
// - subcommands
274+
// - grouped subcommands
275+
// - ungrouped subcommands
234276
return [
235277
Section(header: .positionalArguments, elements: positionalElements),
236278
] + sectionTitles.map { name in
237279
Section(header: .title(name), elements: titledSections[name, default: []])
238280
} + [
239281
Section(header: .options, elements: optionElements),
240-
Section(header: .subcommands, elements: subcommandElements),
241-
]
282+
ungroupedSubcommands
283+
] + groupedSubcommands
242284
}
243285

244286
func usageMessage() -> String {
@@ -247,8 +289,12 @@ internal struct HelpGenerator {
247289
}
248290

249291
var includesSubcommands: Bool {
250-
guard let subcommandSection = sections.first(where: { $0.header == .subcommands })
251-
else { return false }
292+
guard let subcommandSection = sections.first(where: {
293+
switch $0.header {
294+
case .groupedSubcommands(_), .subcommands: return true
295+
case .options, .positionalArguments, .title(_): return false
296+
}
297+
}) else { return false }
252298
return !subcommandSection.elements.isEmpty
253299
}
254300

Tests/ArgumentParserUnitTests/HelpGenerationTests.swift

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,44 @@ extension HelpGenerationTests {
514514
515515
""")
516516
}
517+
518+
struct WithSubgroups: ParsableCommand {
519+
static let configuration = CommandConfiguration(
520+
commandName: "subgroupings"
521+
) {
522+
CommandGroup(name: "Broken") {
523+
Foo.self
524+
Bar.self
525+
}
526+
527+
M.self
528+
529+
CommandGroup(name: "Complicated") {
530+
N.self
531+
}
532+
}
533+
}
534+
535+
func testHelpSubcommandGroups() throws {
536+
AssertHelp(.default, for: WithSubgroups.self, equals: """
537+
USAGE: subgroupings <subcommand>
538+
539+
OPTIONS:
540+
-h, --help Show help information.
541+
542+
SUBCOMMANDS:
543+
m
544+
545+
BROKEN SUBCOMMANDS:
546+
foo Perform some foo
547+
bar Perform bar operations
548+
549+
COMPLICATED SUBCOMMANDS:
550+
n
551+
552+
See 'subgroupings help <subcommand>' for detailed help.
553+
""")
554+
}
517555
}
518556

519557
extension HelpGenerationTests {

0 commit comments

Comments
 (0)