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

Issue:
Group and label option groups in the help screen
Option groups are currently just splatted into the rest of the declared arguments for a command. This is sometimes fine, but often it would be nice if option groups were separated and labeled, which would (a) help readability in complex commands and (b) help users understand which options carry over across multiple subcommands.

For example, for declarations like this:

```swift
struct Options: ParsableArguments {
    @Flag(help: "Display extra information while processing.")
    var verbose = false

    @Option(help: "The number of extra lines to show.")
    var extraLines = 0
}

struct Example: ParsableCommand {
    @OptionGroup(title: "Shared Options")
    var group: Options

    @Flag(help: "Parse as a binary file.")
    var parseAsBinary = false

    @Argument(help: "The input file.")
    var inputFile: String?
}
```

The help screen could look something like:

```

% example --help
USAGE: example [--verbose] [--extra-lines <extra-lines>] [--parse-as-binary] <input-file>

ARGUMENTS:
  <input-file>            The input file.

OPTIONS:
  --parse-as-binary       Parse as a binary file.

SHARED OPTIONS:
  --verbose               Display extra information while processing.
  --extra-lines <extra-lines>
                          The number of extra lines to show. (default: 0)

  -h, --help              Show help information.
```

Pull request:
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:

```swift
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.

Resolves #267.

### Checklist
- [x] I've added at least one test that validates that my change is working, if appropriate
- [x] I've followed the code style of the rest of the project
- [x] I've read the [Contribution Guidelines](https://github.com/apple/swift-argument-parser/blob/main/CONTRIBUTING.md)
- [x] I've updated the documentation if necessary
    - [x] API documentation
    - [x] Articles

Patch:
diff --git a/Sources/ArgumentParser/Documentation.docc/Articles/CustomizingHelp.md b/Sources/ArgumentParser/Documentation.docc/Articles/CustomizingHelp.md
index 02f584939..45030d57f 100644
--- a/Sources/ArgumentParser/Documentation.docc/Articles/CustomizingHelp.md
+++ b/Sources/ArgumentParser/Documentation.docc/Articles/CustomizingHelp.md
@@ -74,7 +74,7 @@ OPTIONS:
   -h, --help              Show help information.
 ```
 
-### Controlling Argument Visibility
+## Controlling Argument Visibility
 
 You can specify the visibility of any argument, option, or flag.
 
@@ -141,3 +141,56 @@ OPTIONS:
                           Use advanced security. (experimental)
   -h, --help              Show help information.
 ```
+
+## Grouping Arguments in the Help Screen
+
+When you provide a title in an `@OptionGroup` declaration, that type's 
+properties are grouped together under your title in the help screen. 
+For example, this command bundles similar arguments together under a 
+"Build Options" title:
+
+```swift
+struct BuildOptions: ParsableArguments {
+    @Option(help: "A setting to pass to the compiler.")
+    var compilerSetting: [String] = []
+
+    @Option(help: "A setting to pass to the linker.")
+    var linkerSetting: [String] = []
+}
+
+struct Example: ParsableCommand {
+    @Argument(help: "The input file to process.")
+    var inputFile: String
+
+    @Flag(help: "Show extra output.")
+    var verbose: Bool = false
+
+    @Option(help: "The path to a configuration file.")
+    var configFile: String?
+
+    @OptionGroup(title: "Build Options")
+    var buildOptions: BuildOptions
+}
+```
+
+This grouping is reflected in the command's help screen:
+
+```
+% example --help
+USAGE: example <input-file> [--verbose] [--config-file <config-file>] [--compiler-setting <compiler-setting> ...] [--linker-setting <linker-setting> ...]
+
+ARGUMENTS:
+  <input-file>            The input file to process.
+
+BUILD OPTIONS:
+  --compiler-setting <compiler-setting>
+                          A setting to pass to the compiler.
+  --linker-setting <linker-setting>
+                          A setting to pass to the linker.
+
+OPTIONS:
+  --verbose               Show extra output.
+  --config-file <config-file>
+                          The path to a configuration file.
+  -h, --help              Show help information.
+```
diff --git a/Sources/ArgumentParser/Parsable Properties/OptionGroup.swift b/Sources/ArgumentParser/Parsable Properties/OptionGroup.swift
index f727fec75..b74865b62 100644
--- a/Sources/ArgumentParser/Parsable Properties/OptionGroup.swift
+++ b/Sources/ArgumentParser/Parsable Properties/OptionGroup.swift
@@ -36,6 +36,9 @@ public struct OptionGroup<Value: ParsableArguments>: Decodable, ParsedWrapper {
   // FIXME: Adding this property works around the crasher described in
   // https://github.com/apple/swift-argument-parser/issues/338
   internal var _dummy: Bool = false
+  
+  /// The title to use in the help screen for this option group.
+  public var title: String = ""
 
   internal init(_parsedValue: Parsed<Value>) {
     self._parsedValue = _parsedValue
@@ -62,12 +65,27 @@ public struct OptionGroup<Value: ParsableArguments>: Decodable, ParsedWrapper {
   }
 
   /// Creates a property that represents another parsable type, using the
-  /// specified visibility.
-  public init(visibility: ArgumentVisibility = .default) {
+  /// specified title and visibility.
+  ///
+  /// - Parameters:
+  ///   - title: A title for grouping this option group's members in your
+  ///     command's help screen. If `title` is empty, the members will be
+  ///     displayed alongside the other arguments, flags, and options declared
+  ///     by your command.
+  ///   - visibility: The visibility to use for the entire option group.
+  public init(
+    title: String = "",
+    visibility: ArgumentVisibility = .default
+  ) {
     self.init(_parsedValue: .init { parentKey in
-      return ArgumentSet(Value.self, visibility: .private, parent: .key(parentKey))
+      var args = ArgumentSet(Value.self, visibility: .private, parent: .key(parentKey))
+      args.content.withEach {
+        $0.help.parentTitle = title
+      }
+      return args
     })
     self._visibility = visibility
+    self.title = title
   }
 
   /// The value presented by this property wrapper.
@@ -111,3 +129,15 @@ extension OptionGroup {
     self.init(visibility: .default)
   }
 }
+
+// MARK: Deprecated
+
+extension OptionGroup {
+  @_disfavoredOverload
+  @available(*, deprecated, renamed: "init(title:visibility:)")
+  public init(
+    visibility _visibility: ArgumentVisibility = .default
+  ) {
+    self.init(title: "", visibility: _visibility)
+  }
+}
diff --git a/Sources/ArgumentParser/Parsing/ArgumentDefinition.swift b/Sources/ArgumentParser/Parsing/ArgumentDefinition.swift
index e8aebb7c2..45410109a 100644
--- a/Sources/ArgumentParser/Parsing/ArgumentDefinition.swift
+++ b/Sources/ArgumentParser/Parsing/ArgumentDefinition.swift
@@ -54,6 +54,7 @@ struct ArgumentDefinition {
     var discussion: String
     var valueName: String
     var visibility: ArgumentVisibility
+    var parentTitle: String
 
     init(
       allValues: [String],
@@ -72,6 +73,7 @@ struct ArgumentDefinition {
       self.discussion = help?.discussion ?? ""
       self.valueName = help?.valueName ?? ""
       self.visibility = help?.visibility ?? .default
+      self.parentTitle = ""
     }
   }
   
diff --git a/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift b/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift
index a7326f87a..27c301888 100644
--- a/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift
+++ b/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift
@@ -126,6 +126,7 @@ fileprivate extension ArgumentInfoV0 {
     self.init(
       kind: kind,
       shouldDisplay: argument.help.visibility.base == .default,
+      sectionTitle: argument.help.parentTitle.nonEmpty,
       isOptional: argument.help.options.contains(.isOptional),
       isRepeating: argument.help.options.contains(.isRepeating),
       names: argument.names.map(ArgumentInfoV0.NameInfoV0.init),
diff --git a/Sources/ArgumentParser/Usage/HelpGenerator.swift b/Sources/ArgumentParser/Usage/HelpGenerator.swift
index 97fa3a9e1..26ff88118 100644
--- a/Sources/ArgumentParser/Usage/HelpGenerator.swift
+++ b/Sources/ArgumentParser/Usage/HelpGenerator.swift
@@ -51,6 +51,7 @@ internal struct HelpGenerator {
       case positionalArguments
       case subcommands
       case options
+      case title(String)
       
       var description: String {
         switch self {
@@ -60,6 +61,8 @@ internal struct HelpGenerator {
           return "Subcommands"
         case .options:
           return "Options"
+        case .title(let name):
+          return name
         }
       }
     }
@@ -136,6 +139,10 @@ internal struct HelpGenerator {
     
     var positionalElements: [Section.Element] = []
     var optionElements: [Section.Element] = []
+    
+    // Simulate an ordered dictionary using a dictionary and array for ordering.
+    var titledSections: [String: [Section.Element]] = [:]
+    var sectionTitles: [String] = []
 
     /// Start with a full slice of the ArgumentSet so we can peel off one or
     /// more elements at a time.
@@ -183,9 +190,15 @@ internal struct HelpGenerator {
       }
       
       let element = Section.Element(label: synopsis, abstract: description, discussion: arg.help.discussion)
-      if case .positional = arg.kind {
+      switch (arg.kind, arg.help.parentTitle) {
+      case (_, let sectionTitle) where !sectionTitle.isEmpty:
+        if !titledSections.keys.contains(sectionTitle) {
+          sectionTitles.append(sectionTitle)
+        }
+        titledSections[sectionTitle, default: []].append(element)
+      case (.positional, _):
         positionalElements.append(element)
-      } else {
+      default:
         optionElements.append(element)
       }
     }
@@ -203,8 +216,16 @@ internal struct HelpGenerator {
           abstract: command.configuration.abstract)
     }
     
+    // Combine the compiled groups in this order:
+    // - arguments
+    // - named sections
+    // - options/flags
+    // - 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),
     ]
diff --git a/Sources/ArgumentParser/Utilities/CollectionExtensions.swift b/Sources/ArgumentParser/Utilities/CollectionExtensions.swift
index 1f86b27a5..dae37f7b5 100644
--- a/Sources/ArgumentParser/Utilities/CollectionExtensions.swift
+++ b/Sources/ArgumentParser/Utilities/CollectionExtensions.swift
@@ -14,3 +14,13 @@ extension Collection {
     isEmpty ? replacement() : self
   }
 }
+
+extension MutableCollection {
+  mutating func withEach(_ body: (inout Element) throws -> Void) rethrows {
+    var i = startIndex
+    while i < endIndex {
+      try body(&self[i])
+      formIndex(after: &i)
+    }
+  }
+}
diff --git a/Sources/ArgumentParser/Utilities/StringExtensions.swift b/Sources/ArgumentParser/Utilities/StringExtensions.swift
index 46413052c..9c1deb090 100644
--- a/Sources/ArgumentParser/Utilities/StringExtensions.swift
+++ b/Sources/ArgumentParser/Utilities/StringExtensions.swift
@@ -132,7 +132,6 @@ extension StringProtocol where SubSequence == Substring {
   ///     // 3
   ///     "bar".editDistance(to: "baz")
   ///     // 1
-
   func editDistance(to target: String) -> Int {
     let rows = self.count
     let columns = target.count
@@ -239,4 +238,8 @@ extension StringProtocol where SubSequence == Substring {
     guard lines.count == 2 else { return lines.joined(separator: "") }
     return "\(lines[0])\n\(lines[1].indentingEachLine(by: n))"
   }
+  
+  var nonEmpty: Self? {
+    isEmpty ? nil : self
+  }
 }
diff --git a/Sources/ArgumentParserToolInfo/ToolInfo.swift b/Sources/ArgumentParserToolInfo/ToolInfo.swift
index 086f8fcc4..daecd28d4 100644
--- a/Sources/ArgumentParserToolInfo/ToolInfo.swift
+++ b/Sources/ArgumentParserToolInfo/ToolInfo.swift
@@ -122,6 +122,9 @@ public struct ArgumentInfoV0: Codable, Hashable {
 
   /// Argument should appear in help displays.
   public var shouldDisplay: Bool
+  /// Custom name of argument's section.
+  public var sectionTitle: String?
+  
   /// Argument can be omitted.
   public var isOptional: Bool
   /// Argument can be specified multiple times.
@@ -147,6 +150,7 @@ public struct ArgumentInfoV0: Codable, Hashable {
   public init(
     kind: KindV0,
     shouldDisplay: Bool,
+    sectionTitle: String?,
     isOptional: Bool,
     isRepeating: Bool,
     names: [NameInfoV0]?,
@@ -160,6 +164,8 @@ public struct ArgumentInfoV0: Codable, Hashable {
     self.kind = kind
 
     self.shouldDisplay = shouldDisplay
+    self.sectionTitle = sectionTitle
+    
     self.isOptional = isOptional
     self.isRepeating = isRepeating
 


Test patch:
diff --git a/Tests/ArgumentParserUnitTests/CMakeLists.txt b/Tests/ArgumentParserUnitTests/CMakeLists.txt
index 476219c7b..02088dbb3 100644
--- a/Tests/ArgumentParserUnitTests/CMakeLists.txt
+++ b/Tests/ArgumentParserUnitTests/CMakeLists.txt
@@ -4,6 +4,7 @@ add_library(UnitTests
   HelpGenerationTests.swift
   HelpGenerationTests+AtArgument.swift
   HelpGenerationTests+AtOption.swift
+  HelpGenerationTests+GroupName.swift
   NameSpecificationTests.swift
   SplitArgumentTests.swift
   StringSnakeCaseTests.swift
diff --git a/Tests/ArgumentParserUnitTests/DumpHelpGenerationTests.swift b/Tests/ArgumentParserUnitTests/DumpHelpGenerationTests.swift
index f6c119e83..4d9a052fc 100644
--- a/Tests/ArgumentParserUnitTests/DumpHelpGenerationTests.swift
+++ b/Tests/ArgumentParserUnitTests/DumpHelpGenerationTests.swift
@@ -50,10 +50,24 @@ extension DumpHelpGenerationTests {
     var argWithDefaultValue: Int = 1
   }
   
+  struct Options: ParsableArguments {
+    @Flag var verbose = false
+    @Option var name: String
+  }
+  
+  struct B: ParsableCommand {
+    @OptionGroup(title: "Other")
+    var options: Options
+  }
+  
   public func testDumpA() throws {
     try AssertDump(for: A.self, equals: Self.aDumpText)
   }
   
+  public func testDumpB() throws {
+    try AssertDump(for: B.self, equals: Self.bDumpText)
+  }
+  
   public func testDumpExampleCommands() throws {
     struct TestCase {
       let expected: String
@@ -233,6 +247,75 @@ extension DumpHelpGenerationTests {
 }
 """
 
+  static let bDumpText: String = """
+{
+  "command" : {
+    "arguments" : [
+      {
+        "isOptional" : true,
+        "isRepeating" : false,
+        "kind" : "flag",
+        "names" : [
+          {
+            "kind" : "long",
+            "name" : "verbose"
+          }
+        ],
+        "preferredName" : {
+          "kind" : "long",
+          "name" : "verbose"
+        },
+        "sectionTitle" : "Other",
+        "shouldDisplay" : true,
+        "valueName" : "verbose"
+      },
+      {
+        "isOptional" : false,
+        "isRepeating" : false,
+        "kind" : "option",
+        "names" : [
+          {
+            "kind" : "long",
+            "name" : "name"
+          }
+        ],
+        "preferredName" : {
+          "kind" : "long",
+          "name" : "name"
+        },
+        "sectionTitle" : "Other",
+        "shouldDisplay" : true,
+        "valueName" : "name"
+      },
+      {
+        "abstract" : "Show help information.",
+        "isOptional" : true,
+        "isRepeating" : false,
+        "kind" : "flag",
+        "names" : [
+          {
+            "kind" : "short",
+            "name" : "h"
+          },
+          {
+            "kind" : "long",
+            "name" : "help"
+          }
+        ],
+        "preferredName" : {
+          "kind" : "long",
+          "name" : "help"
+        },
+        "shouldDisplay" : true,
+        "valueName" : "help"
+      }
+    ],
+    "commandName" : "b"
+  },
+  "serializationVersion" : 0
+}
+"""
+  
   static let mathDumpText: String = """
 {
   "command" : {
diff --git a/Tests/ArgumentParserUnitTests/HelpGenerationTests+GroupName.swift b/Tests/ArgumentParserUnitTests/HelpGenerationTests+GroupName.swift
new file mode 100644
index 000000000..947d0c461
--- /dev/null
+++ b/Tests/ArgumentParserUnitTests/HelpGenerationTests+GroupName.swift
@@ -0,0 +1,371 @@
+//===----------------------------------------------------------*- swift -*-===//
+//
+// This source file is part of the Swift Argument Parser open source project
+//
+// Copyright (c) 2022 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
+//
+//===----------------------------------------------------------------------===//
+
+import XCTest
+import ArgumentParserTestHelpers
+@testable import ArgumentParser
+
+// This set of tests assert the help output matches the expected value for
+// `@OptionGroup`s with names.
+
+extension HelpGenerationTests {
+  fileprivate struct Flags: ParsableArguments {
+    @Flag(help: "example")
+    var verbose: Bool = false
+    
+    @Flag(help: "example")
+    var oversharing: Bool = false
+  }
+  
+  fileprivate struct Options: ParsableArguments {
+    @Option(help: "example")
+    var name: String = ""
+    
+    @Option(help: "example")
+    var age: Int
+  }
+  
+  fileprivate struct FlagsAndOptions: ParsableArguments {
+    @Flag(help: "example")
+    var experimental: Bool = false
+    
+    @Option(help: "example")
+    var prefix: String
+  }
+  
+  fileprivate struct ArgsAndFlags: ParsableArguments {
+    @Argument(help: "example")
+    var name: String?
+    
+    @Argument(help: .init("example", visibility: .hidden))
+    var title: String
+    
+    @Flag(help: "example")
+    var existingUser: Bool = false
+  }
+  
+  fileprivate struct AllVisible: ParsableCommand {
+    @OptionGroup(title: "Flags Group")
+    var flags: Flags
+    
+    @OptionGroup(title: "Options Group")
+    var options: Options
+    
+    @OptionGroup
+    var flagsAndOptions: FlagsAndOptions
+    
+    @OptionGroup
+    var argsAndFlags: ArgsAndFlags
+  }
+  
+  fileprivate struct ContainsOptionGroup: ParsableCommand {
+    @OptionGroup(title: "Flags Group")
+    var flags: Flags
+    
+    @OptionGroup
+    var argsAndFlags: ArgsAndFlags
+  }
+  
+  func testAllVisible() {
+    AssertHelp(.default, for: AllVisible.self, equals: """
+      USAGE: all-visible [--verbose] [--oversharing] [--name <name>] --age <age> [--experimental] --prefix <prefix> [<name>] [--existing-user]
+
+      ARGUMENTS:
+        <name>                  example
+
+      FLAGS GROUP:
+        --verbose               example
+        --oversharing           example
+
+      OPTIONS GROUP:
+        --name <name>           example
+        --age <age>             example
+
+      OPTIONS:
+        --experimental          example
+        --prefix <prefix>       example
+        --existing-user         example
+        -h, --help              Show help information.
+
+      """)
+    
+    AssertHelp(.hidden, for: AllVisible.self, equals: """
+      USAGE: all-visible [--verbose] [--oversharing] [--name <name>] --age <age> [--experimental] --prefix <prefix> [<name>] <title> [--existing-user]
+
+      ARGUMENTS:
+        <name>                  example
+        <title>                 example
+
+      FLAGS GROUP:
+        --verbose               example
+        --oversharing           example
+
+      OPTIONS GROUP:
+        --name <name>           example
+        --age <age>             example
+
+      OPTIONS:
+        --experimental          example
+        --prefix <prefix>       example
+        --existing-user         example
+        -h, --help              Show help information.
+
+      """)
+  }
+  
+  fileprivate struct Combined: ParsableCommand {
+    @OptionGroup(title: "Extras")
+    var flags: Flags
+    
+    @OptionGroup(title: "Extras")
+    var options: Options
+    
+    @OptionGroup(title: "Others")
+    var flagsAndOptions: FlagsAndOptions
+    
+    @OptionGroup(title: "Others")
+    var argsAndFlags: ArgsAndFlags
+  }
+  
+  func testCombined() {
+    AssertHelp(.default, for: Combined.self, equals: """
+      USAGE: combined [--verbose] [--oversharing] [--name <name>] --age <age> [--experimental] --prefix <prefix> [<name>] [--existing-user]
+
+      EXTRAS:
+        --verbose               example
+        --oversharing           example
+        --name <name>           example
+        --age <age>             example
+
+      OTHERS:
+        --experimental          example
+        --prefix <prefix>       example
+        <name>                  example
+        --existing-user         example
+
+      OPTIONS:
+        -h, --help              Show help information.
+
+      """)
+    
+    AssertHelp(.hidden, for: Combined.self, equals: """
+      USAGE: combined [--verbose] [--oversharing] [--name <name>] --age <age> [--experimental] --prefix <prefix> [<name>] <title> [--existing-user]
+
+      EXTRAS:
+        --verbose               example
+        --oversharing           example
+        --name <name>           example
+        --age <age>             example
+
+      OTHERS:
+        --experimental          example
+        --prefix <prefix>       example
+        <name>                  example
+        <title>                 example
+        --existing-user         example
+
+      OPTIONS:
+        -h, --help              Show help information.
+
+      """)
+  }
+  
+  fileprivate struct HiddenGroups: ParsableCommand {
+    @OptionGroup(title: "Flags Group", visibility: .hidden)
+    var flags: Flags
+    
+    @OptionGroup(title: "Options Group", visibility: .hidden)
+    var options: Options
+    
+    @OptionGroup(visibility: .hidden)
+    var flagsAndOptions: FlagsAndOptions
+    
+    @OptionGroup(visibility: .private)
+    var argsAndFlags: ArgsAndFlags
+  }
+  
+  func testHiddenGroups() {
+    AssertHelp(.default, for: HiddenGroups.self, equals: """
+      USAGE: hidden-groups
+
+      OPTIONS:
+        -h, --help              Show help information.
+
+      """)
+    
+    AssertHelp(.hidden, for: HiddenGroups.self, equals: """
+      USAGE: hidden-groups [--verbose] [--oversharing] [--name <name>] --age <age> [--experimental] --prefix <prefix>
+
+      FLAGS GROUP:
+        --verbose               example
+        --oversharing           example
+
+      OPTIONS GROUP:
+        --name <name>           example
+        --age <age>             example
+
+      OPTIONS:
+        --experimental          example
+        --prefix <prefix>       example
+        -h, --help              Show help information.
+
+      """)
+  }
+  
+  fileprivate struct ParentWithGroups: ParsableCommand {
+    static var configuration: CommandConfiguration {
+      .init(subcommands: [ChildWithGroups.self])
+    }
+    
+    @OptionGroup(title: "Extras")
+    var flags: Flags
+        
+    @OptionGroup
+    var argsAndFlags: ArgsAndFlags
+  
+    fileprivate struct ChildWithGroups: ParsableCommand {
+      @OptionGroup(title: "Child Extras")
+      var flags: Flags
+
+      @OptionGroup(title: "Extras")
+      var options: Options
+
+      @OptionGroup
+      var argsAndFlags: ArgsAndFlags
+    }
+  }
+
+  func testParentChild() {
+    AssertHelp(.default, for: ParentWithGroups.self, equals: """
+      USAGE: parent-with-groups [--verbose] [--oversharing] [<name>] [--existing-user] <subcommand>
+
+      ARGUMENTS:
+        <name>                  example
+
+      EXTRAS:
+        --verbose               example
+        --oversharing           example
+
+      OPTIONS:
+        --existing-user         example
+        -h, --help              Show help information.
+
+      SUBCOMMANDS:
+        child-with-groups
+
+        See 'parent-with-groups help <subcommand>' for detailed help.
+      """)
+    
+    AssertHelp(.hidden, for: ParentWithGroups.self, equals: """
+      USAGE: parent-with-groups [--verbose] [--oversharing] [<name>] <title> [--existing-user] <subcommand>
+
+      ARGUMENTS:
+        <name>                  example
+        <title>                 example
+
+      EXTRAS:
+        --verbose               example
+        --oversharing           example
+
+      OPTIONS:
+        --existing-user         example
+        -h, --help              Show help information.
+
+      SUBCOMMANDS:
+        child-with-groups
+
+        See 'parent-with-groups help <subcommand>' for detailed help.
+      """)
+    
+    AssertHelp(.default, for: ParentWithGroups.ChildWithGroups.self, root: ParentWithGroups.self, equals: """
+      USAGE: parent-with-groups child-with-groups [--verbose] [--oversharing] [--name <name>] --age <age> [<name>] [--existing-user]
+
+      ARGUMENTS:
+        <name>                  example
+
+      CHILD EXTRAS:
+        --verbose               example
+        --oversharing           example
+
+      EXTRAS:
+        --name <name>           example
+        --age <age>             example
+
+      OPTIONS:
+        --existing-user         example
+        -h, --help              Show help information.
+
+      """)
+
+    AssertHelp(.hidden, for: ParentWithGroups.ChildWithGroups.self, root: ParentWithGroups.self, equals: """
+      USAGE: parent-with-groups child-with-groups [--verbose] [--oversharing] [--name <name>] --age <age> [<name>] <title> [--existing-user]
+
+      ARGUMENTS:
+        <name>                  example
+        <title>                 example
+
+      CHILD EXTRAS:
+        --verbose               example
+        --oversharing           example
+
+      EXTRAS:
+        --name <name>           example
+        --age <age>             example
+
+      OPTIONS:
+        --existing-user         example
+        -h, --help              Show help information.
+
+      """)
+  }
+
+  fileprivate struct GroupsWithUnnamedGroups: ParsableCommand {
+    @OptionGroup
+    var extras: ContainsOptionGroup
+  }
+
+  func testUnnamedNestedGroups() {
+    AssertHelp(.default, for: GroupsWithUnnamedGroups.self, equals: """
+      USAGE: groups-with-unnamed-groups [--verbose] [--oversharing] [<name>] [--existing-user]
+
+      ARGUMENTS:
+        <name>                  example
+
+      OPTIONS:
+        --verbose               example
+        --oversharing           example
+        --existing-user         example
+        -h, --help              Show help information.
+      
+      """)
+  }
+
+  fileprivate struct GroupsWithNamedGroups: ParsableCommand {
+    @OptionGroup(title: "Nested")
+    var extras: ContainsOptionGroup
+  }
+
+  func testNamedNestedGroups() {
+    AssertHelp(.default, for: GroupsWithNamedGroups.self, equals: """
+      USAGE: groups-with-named-groups [--verbose] [--oversharing] [<name>] [--existing-user]
+
+      NESTED:
+        --verbose               example
+        --oversharing           example
+        <name>                  example
+        --existing-user         example
+
+      OPTIONS:
+        -h, --help              Show help information.
+      
+      """)
+  }
+}