diff --git a/Sources/ArgumentParser/Parsable Types/CommandConfiguration.swift b/Sources/ArgumentParser/Parsable Types/CommandConfiguration.swift index b208c1ea..c86a8d66 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 00000000..3760ec44 --- /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 6dfb411a..719ecd25 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 } diff --git a/Tests/ArgumentParserUnitTests/HelpGenerationTests.swift b/Tests/ArgumentParserUnitTests/HelpGenerationTests.swift index 047eae80..19d29f14 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 + + OPTIONS: + -h, --help Show help information. + + SUBCOMMANDS: + m + + BROKEN SUBCOMMANDS: + foo Perform some foo + bar Perform bar operations + + COMPLICATED SUBCOMMANDS: + n + + See 'subgroupings help ' 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 + + OPTIONS: + -h, --help Show help information. + + BROKEN SUBCOMMANDS: + foo Perform some foo + bar Perform bar operations + + COMPLICATED SUBCOMMANDS: + m + n + + See 'subgroupings help ' for detailed help. + """) + } } extension HelpGenerationTests {