Repository: apple/swift-argument-parser (swift)

Issue:
Add ability to group commands
We can group options today, it would be nice if we could do the same with commands with many subcommands.

Pull request description:
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.

The help output above is created like this:

```
  struct WithSubgroups: ParsableCommand {
    static let configuration = CommandConfiguration(
      commandName: "subgroupings",
      subcommands: [ M.self ],
      groupedSubcommands: [
        CommandGroup(
          name: "Broken",
          subcommands: [ Foo.self, Bar.self ]
        ),
        CommandGroup(name: "Complicated", subcommands: [ N.self ]),
        CommandGroup(name: "Invisible", subcommands: [ ])
      ]
    )
  }
```

Each `CommandGroup` names a new group with its subcommands (there are no groups within groups).

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.

Patch:
diff --git a/Sources/ArgumentParser/Parsable Types/CommandConfiguration.swift b/Sources/ArgumentParser/Parsable Types/CommandConfiguration.swift
index b208c1ea1..c86a8d66d 100644
--- a/Sources/ArgumentParser/Parsable Types/CommandConfiguration.swift
+++ b/Sources/ArgumentParser/Parsable Types/CommandConfiguration.swift
@@ -47,8 +47,27 @@ public struct CommandConfiguration: Sendable {
   public var shouldDisplay: Bool
   
   /// An array of the types that define subcommands for this command.
-  public var subcommands: [ParsableCommand.Type]
-  
+  ///
+  /// This property "flattens" the grouping structure of the subcommands.
+  /// Use 'ungroupedSubcommands' to access 'groupedSubcommands' to retain the grouping structure.
+  public var subcommands: [ParsableCommand.Type] {
+    get {
+      return ungroupedSubcommands + groupedSubcommands.flatMap { $0.subcommands }
+    }
+
+    set {
+      groupedSubcommands = []
+      ungroupedSubcommands = newValue
+    }
+  }
+
+  /// An array of types that define subcommands for this command and are
+  /// not part of any command group.
+  public var ungroupedSubcommands: [ParsableCommand.Type]
+
+  /// The list of subcommands and subcommand groups.
+  public var groupedSubcommands: [CommandGroup]
+
   /// The default command type to run if no subcommand is given.
   public var defaultSubcommand: ParsableCommand.Type?
   
@@ -79,8 +98,10 @@ public struct CommandConfiguration: Sendable {
   ///     a `--version` flag.
   ///   - shouldDisplay: A Boolean value indicating whether the command
   ///     should be shown in the extended help display.
-  ///   - subcommands: An array of the types that define subcommands for the
-  ///     command.
+  ///   - ungroupedSubcommands: An array of the types that define subcommands
+  ///     for the command that are not part of any command group.
+  ///   - groupedSubcommands: An array of command groups, each of which defines
+  ///     subcommands that are part of that logical group.
   ///   - defaultSubcommand: The default command type to run if no subcommand
   ///     is given.
   ///   - helpNames: The flag names to use for requesting help, when combined
@@ -97,7 +118,8 @@ public struct CommandConfiguration: Sendable {
     discussion: String = "",
     version: String = "",
     shouldDisplay: Bool = true,
-    subcommands: [ParsableCommand.Type] = [],
+    subcommands ungroupedSubcommands: [ParsableCommand.Type] = [],
+    groupedSubcommands: [CommandGroup] = [],
     defaultSubcommand: ParsableCommand.Type? = nil,
     helpNames: NameSpecification? = nil,
     aliases: [String] = []
@@ -108,7 +130,8 @@ public struct CommandConfiguration: Sendable {
     self.discussion = discussion
     self.version = version
     self.shouldDisplay = shouldDisplay
-    self.subcommands = subcommands
+    self.ungroupedSubcommands = ungroupedSubcommands
+    self.groupedSubcommands = groupedSubcommands
     self.defaultSubcommand = defaultSubcommand
     self.helpNames = helpNames
     self.aliases = aliases
@@ -124,7 +147,8 @@ public struct CommandConfiguration: Sendable {
     discussion: String = "",
     version: String = "",
     shouldDisplay: Bool = true,
-    subcommands: [ParsableCommand.Type] = [],
+    subcommands ungroupedSubcommands: [ParsableCommand.Type] = [],
+    groupedSubcommands: [CommandGroup] = [],
     defaultSubcommand: ParsableCommand.Type? = nil,
     helpNames: NameSpecification? = nil,
     aliases: [String] = []
@@ -136,7 +160,8 @@ public struct CommandConfiguration: Sendable {
     self.discussion = discussion
     self.version = version
     self.shouldDisplay = shouldDisplay
-    self.subcommands = subcommands
+    self.ungroupedSubcommands = ungroupedSubcommands
+    self.groupedSubcommands = groupedSubcommands
     self.defaultSubcommand = defaultSubcommand
     self.helpNames = helpNames
     self.aliases = aliases
diff --git a/Sources/ArgumentParser/Parsable Types/CommandGroup.swift b/Sources/ArgumentParser/Parsable Types/CommandGroup.swift
new file mode 100644
index 000000000..3760ec443
--- /dev/null
+++ b/Sources/ArgumentParser/Parsable Types/CommandGroup.swift
@@ -0,0 +1,28 @@
+//===----------------------------------------------------------*- swift -*-===//
+//
+// This source file is part of the Swift Argument Parser open source project
+//
+// Copyright (c) 2020 Apple Inc. and the Swift project authors
+// Licensed under Apache License v2.0 with Runtime Library Exception
+//
+// See https://swift.org/LICENSE.txt for license information
+//
+//===----------------------------------------------------------------------===//
+
+/// A set of commands grouped together under a common name.
+public struct CommandGroup: Sendable {
+  /// The name of the command group that will be displayed in help.
+  public let name: String
+
+  /// The list of subcommands that are part of this group.
+  public let subcommands: [ParsableCommand.Type]
+
+  /// Create a command group.
+  public init(
+    name: String,
+    subcommands: [ParsableCommand.Type]
+  ) {
+    self.name = name
+    self.subcommands = subcommands
+  }
+}
diff --git a/Sources/ArgumentParser/Usage/HelpGenerator.swift b/Sources/ArgumentParser/Usage/HelpGenerator.swift
index 6dfb411a5..719ecd250 100644
--- a/Sources/ArgumentParser/Usage/HelpGenerator.swift
+++ b/Sources/ArgumentParser/Usage/HelpGenerator.swift
@@ -52,7 +52,8 @@ internal struct HelpGenerator {
       case subcommands
       case options
       case title(String)
-      
+      case groupedSubcommands(String)
+
       var description: String {
         switch self {
         case .positionalArguments:
@@ -63,6 +64,8 @@ internal struct HelpGenerator {
           return "Options"
         case .title(let name):
           return name
+        case .groupedSubcommands(let name):
+          return "\(name) Subcommands"
         }
       }
     }
@@ -211,34 +214,67 @@ internal struct HelpGenerator {
     }
     
     let configuration = commandStack.last!.configuration
-    let subcommandElements: [Section.Element] =
-      configuration.subcommands.compactMap { command in
-        guard command.configuration.shouldDisplay else { return nil }
-        var label = command._commandName
-        for alias in command.configuration.aliases {
-            label += ", \(alias)"
-        }
-        if command == configuration.defaultSubcommand {
-            label += " (default)"
-        }
-        return Section.Element(
-          label: label,
-          abstract: command.configuration.abstract)
+
+    // Create section for a grouping of subcommands.
+    func subcommandSection(
+      header: Section.Header, 
+      subcommands: [ParsableCommand.Type]
+    ) -> Section {
+      let subcommandElements: [Section.Element] =
+        subcommands.compactMap { command in
+          guard command.configuration.shouldDisplay else { return nil }
+          var label = command._commandName
+          for alias in command.configuration.aliases {
+              label += ", \(alias)"
+          }
+          if command == configuration.defaultSubcommand {
+              label += " (default)"
+          }
+          return Section.Element(
+            label: label,
+            abstract: command.configuration.abstract)
+      }
+
+      return Section(header: header, elements: subcommandElements)
     }
-    
+
+    // All of the subcommand sections.
+    var subcommands: [Section] = []
+
+    // Add section for the ungrouped subcommands, if there are any.
+    if !configuration.ungroupedSubcommands.isEmpty {
+      subcommands.append(
+        subcommandSection(
+          header: .subcommands,
+          subcommands: configuration.ungroupedSubcommands
+        )
+      )
+    }
+
+    // Add sections for all of the grouped subcommands.
+    subcommands.append(
+      contentsOf: configuration.groupedSubcommands
+        .compactMap { group in
+          return subcommandSection(
+            header: .groupedSubcommands(group.name),
+            subcommands: group.subcommands
+          )
+        }
+    )
+
     // Combine the compiled groups in this order:
     // - arguments
     // - named sections
     // - options/flags
-    // - subcommands
+    // - ungrouped subcommands
+    // - grouped subcommands
     return [
       Section(header: .positionalArguments, elements: positionalElements),
     ] + sectionTitles.map { name in
       Section(header: .title(name), elements: titledSections[name, default: []])
     } + [
       Section(header: .options, elements: optionElements),
-      Section(header: .subcommands, elements: subcommandElements),
-    ]
+    ] + subcommands
   }
   
   func usageMessage() -> String {
@@ -247,8 +283,12 @@ internal struct HelpGenerator {
   }
   
   var includesSubcommands: Bool {
-    guard let subcommandSection = sections.first(where: { $0.header == .subcommands })
-      else { return false }
+    guard let subcommandSection = sections.first(where: { 
+      switch $0.header {
+      case .groupedSubcommands, .subcommands: return true
+      case .options, .positionalArguments, .title(_): return false
+      }
+    }) else { return false }
     return !subcommandSection.elements.isEmpty
   }
   


Test patch:
diff --git a/Tests/ArgumentParserUnitTests/HelpGenerationTests.swift b/Tests/ArgumentParserUnitTests/HelpGenerationTests.swift
index 047eae80c..19d29f14f 100644
--- a/Tests/ArgumentParserUnitTests/HelpGenerationTests.swift
+++ b/Tests/ArgumentParserUnitTests/HelpGenerationTests.swift
@@ -514,6 +514,76 @@ extension HelpGenerationTests {
     
     """)
   }
+
+  struct WithSubgroups: ParsableCommand {
+    static let configuration = CommandConfiguration(
+      commandName: "subgroupings",
+      subcommands: [ M.self ],
+      groupedSubcommands: [
+        CommandGroup(
+          name: "Broken",
+          subcommands: [ Foo.self, Bar.self ]
+        ),
+        CommandGroup(name: "Complicated", subcommands: [ N.self ])
+      ]
+    )
+  }
+
+  func testHelpSubcommandGroups() throws {
+    AssertHelp(.default, for: WithSubgroups.self, equals: """
+    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.
+    """)
+  }
+
+  struct OnlySubgroups: ParsableCommand {
+    static let configuration = CommandConfiguration(
+      commandName: "subgroupings",
+      groupedSubcommands: [
+        CommandGroup(
+          name: "Broken",
+          subcommands: [ Foo.self, Bar.self ]
+        ),
+        CommandGroup(
+          name: "Complicated",
+          subcommands: [ M.self, N.self ]
+        )
+      ]
+    )
+  }
+
+  func testHelpOnlySubcommandGroups() throws {
+    AssertHelp(.default, for: OnlySubgroups.self, equals: """
+    USAGE: subgroupings <subcommand>
+
+    OPTIONS:
+      -h, --help              Show help information.
+
+    BROKEN SUBCOMMANDS:
+      foo                     Perform some foo
+      bar                     Perform bar operations
+
+    COMPLICATED SUBCOMMANDS:
+      m
+      n
+
+      See 'subgroupings help <subcommand>' for detailed help.
+    """)
+  }
 }
 
 extension HelpGenerationTests {