Skip to content

Commit 516c2f8

Browse files
authored
Introduce subcommand grouping into the command configuration to improve help (#644)
* 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. * Add an optional abstract to command groups * Expand subcommand group result builders to handle all result-builder syntax Adds support for if, if-else, if #available, and for..in loops. * Revert "Add an optional abstract to command groups" This reverts commit ab563a2. * Eliminate result builders in favor of a second "groupedSubcommands" array Introduce subcommand groups with a more modest extension to the API that adds another array of subcommand groups alongside the (ungrouped) subcommands array. We can consider introducing result builders as a separate step later, if there's more to be gained from it. * Drop the (ungrouped) "subcommands" heading when there are none.
1 parent 0fbc884 commit 516c2f8

File tree

4 files changed

+191
-28
lines changed

4 files changed

+191
-28
lines changed

Sources/ArgumentParser/Parsable Types/CommandConfiguration.swift

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,27 @@ 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 'ungroupedSubcommands' to access 'groupedSubcommands' to retain the grouping structure.
53+
public var subcommands: [ParsableCommand.Type] {
54+
get {
55+
return ungroupedSubcommands + groupedSubcommands.flatMap { $0.subcommands }
56+
}
57+
58+
set {
59+
groupedSubcommands = []
60+
ungroupedSubcommands = newValue
61+
}
62+
}
63+
64+
/// An array of types that define subcommands for this command and are
65+
/// not part of any command group.
66+
public var ungroupedSubcommands: [ParsableCommand.Type]
67+
68+
/// The list of subcommands and subcommand groups.
69+
public var groupedSubcommands: [CommandGroup]
70+
5271
/// The default command type to run if no subcommand is given.
5372
public var defaultSubcommand: ParsableCommand.Type?
5473

@@ -79,8 +98,10 @@ public struct CommandConfiguration: Sendable {
7998
/// a `--version` flag.
8099
/// - shouldDisplay: A Boolean value indicating whether the command
81100
/// should be shown in the extended help display.
82-
/// - subcommands: An array of the types that define subcommands for the
83-
/// command.
101+
/// - ungroupedSubcommands: An array of the types that define subcommands
102+
/// for the command that are not part of any command group.
103+
/// - groupedSubcommands: An array of command groups, each of which defines
104+
/// subcommands that are part of that logical group.
84105
/// - defaultSubcommand: The default command type to run if no subcommand
85106
/// is given.
86107
/// - helpNames: The flag names to use for requesting help, when combined
@@ -97,7 +118,8 @@ public struct CommandConfiguration: Sendable {
97118
discussion: String = "",
98119
version: String = "",
99120
shouldDisplay: Bool = true,
100-
subcommands: [ParsableCommand.Type] = [],
121+
subcommands ungroupedSubcommands: [ParsableCommand.Type] = [],
122+
groupedSubcommands: [CommandGroup] = [],
101123
defaultSubcommand: ParsableCommand.Type? = nil,
102124
helpNames: NameSpecification? = nil,
103125
aliases: [String] = []
@@ -108,7 +130,8 @@ public struct CommandConfiguration: Sendable {
108130
self.discussion = discussion
109131
self.version = version
110132
self.shouldDisplay = shouldDisplay
111-
self.subcommands = subcommands
133+
self.ungroupedSubcommands = ungroupedSubcommands
134+
self.groupedSubcommands = groupedSubcommands
112135
self.defaultSubcommand = defaultSubcommand
113136
self.helpNames = helpNames
114137
self.aliases = aliases
@@ -124,7 +147,8 @@ public struct CommandConfiguration: Sendable {
124147
discussion: String = "",
125148
version: String = "",
126149
shouldDisplay: Bool = true,
127-
subcommands: [ParsableCommand.Type] = [],
150+
subcommands ungroupedSubcommands: [ParsableCommand.Type] = [],
151+
groupedSubcommands: [CommandGroup] = [],
128152
defaultSubcommand: ParsableCommand.Type? = nil,
129153
helpNames: NameSpecification? = nil,
130154
aliases: [String] = []
@@ -136,7 +160,8 @@ public struct CommandConfiguration: Sendable {
136160
self.discussion = discussion
137161
self.version = version
138162
self.shouldDisplay = shouldDisplay
139-
self.subcommands = subcommands
163+
self.ungroupedSubcommands = ungroupedSubcommands
164+
self.groupedSubcommands = groupedSubcommands
140165
self.defaultSubcommand = defaultSubcommand
141166
self.helpNames = helpNames
142167
self.aliases = aliases
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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+
subcommands: [ParsableCommand.Type]
24+
) {
25+
self.name = name
26+
self.subcommands = subcommands
27+
}
28+
}

Sources/ArgumentParser/Usage/HelpGenerator.swift

Lines changed: 60 additions & 20 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,67 @@ 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)"
220-
}
221-
if command == configuration.defaultSubcommand {
222-
label += " (default)"
223-
}
224-
return Section.Element(
225-
label: label,
226-
abstract: command.configuration.abstract)
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)
227239
}
228-
240+
241+
// All of the subcommand sections.
242+
var subcommands: [Section] = []
243+
244+
// Add section for the ungrouped subcommands, if there are any.
245+
if !configuration.ungroupedSubcommands.isEmpty {
246+
subcommands.append(
247+
subcommandSection(
248+
header: .subcommands,
249+
subcommands: configuration.ungroupedSubcommands
250+
)
251+
)
252+
}
253+
254+
// Add sections for all of the grouped subcommands.
255+
subcommands.append(
256+
contentsOf: configuration.groupedSubcommands
257+
.compactMap { group in
258+
return subcommandSection(
259+
header: .groupedSubcommands(group.name),
260+
subcommands: group.subcommands
261+
)
262+
}
263+
)
264+
229265
// Combine the compiled groups in this order:
230266
// - arguments
231267
// - named sections
232268
// - options/flags
233-
// - subcommands
269+
// - ungrouped subcommands
270+
// - grouped subcommands
234271
return [
235272
Section(header: .positionalArguments, elements: positionalElements),
236273
] + sectionTitles.map { name in
237274
Section(header: .title(name), elements: titledSections[name, default: []])
238275
} + [
239276
Section(header: .options, elements: optionElements),
240-
Section(header: .subcommands, elements: subcommandElements),
241-
]
277+
] + subcommands
242278
}
243279

244280
func usageMessage() -> String {
@@ -247,8 +283,12 @@ internal struct HelpGenerator {
247283
}
248284

249285
var includesSubcommands: Bool {
250-
guard let subcommandSection = sections.first(where: { $0.header == .subcommands })
251-
else { return false }
286+
guard let subcommandSection = sections.first(where: {
287+
switch $0.header {
288+
case .groupedSubcommands, .subcommands: return true
289+
case .options, .positionalArguments, .title(_): return false
290+
}
291+
}) else { return false }
252292
return !subcommandSection.elements.isEmpty
253293
}
254294

Tests/ArgumentParserUnitTests/HelpGenerationTests.swift

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,76 @@ extension HelpGenerationTests {
514514
515515
""")
516516
}
517+
518+
struct WithSubgroups: ParsableCommand {
519+
static let configuration = CommandConfiguration(
520+
commandName: "subgroupings",
521+
subcommands: [ M.self ],
522+
groupedSubcommands: [
523+
CommandGroup(
524+
name: "Broken",
525+
subcommands: [ Foo.self, Bar.self ]
526+
),
527+
CommandGroup(name: "Complicated", subcommands: [ N.self ])
528+
]
529+
)
530+
}
531+
532+
func testHelpSubcommandGroups() throws {
533+
AssertHelp(.default, for: WithSubgroups.self, equals: """
534+
USAGE: subgroupings <subcommand>
535+
536+
OPTIONS:
537+
-h, --help Show help information.
538+
539+
SUBCOMMANDS:
540+
m
541+
542+
BROKEN SUBCOMMANDS:
543+
foo Perform some foo
544+
bar Perform bar operations
545+
546+
COMPLICATED SUBCOMMANDS:
547+
n
548+
549+
See 'subgroupings help <subcommand>' for detailed help.
550+
""")
551+
}
552+
553+
struct OnlySubgroups: ParsableCommand {
554+
static let configuration = CommandConfiguration(
555+
commandName: "subgroupings",
556+
groupedSubcommands: [
557+
CommandGroup(
558+
name: "Broken",
559+
subcommands: [ Foo.self, Bar.self ]
560+
),
561+
CommandGroup(
562+
name: "Complicated",
563+
subcommands: [ M.self, N.self ]
564+
)
565+
]
566+
)
567+
}
568+
569+
func testHelpOnlySubcommandGroups() throws {
570+
AssertHelp(.default, for: OnlySubgroups.self, equals: """
571+
USAGE: subgroupings <subcommand>
572+
573+
OPTIONS:
574+
-h, --help Show help information.
575+
576+
BROKEN SUBCOMMANDS:
577+
foo Perform some foo
578+
bar Perform bar operations
579+
580+
COMPLICATED SUBCOMMANDS:
581+
m
582+
n
583+
584+
See 'subgroupings help <subcommand>' for detailed help.
585+
""")
586+
}
517587
}
518588

519589
extension HelpGenerationTests {

0 commit comments

Comments
 (0)