Skip to content

Introduce subcommand grouping into the command configuration to improve help #644

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jun 4, 2024

Conversation

DougGregor
Copy link
Member

@DougGregor DougGregor commented Jun 2, 2024

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.

…ve 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.
@DougGregor
Copy link
Member Author

@swift-ci please test

@rauhul rauhul linked an issue Jun 3, 2024 that may be closed by this pull request
@dickoff
Copy link

dickoff commented Jun 3, 2024

I was just looking at doing something very similar! (WIP here: https://github.com/apple/swift-argument-parser/tree/subcommand-groups).

The CommandsBuilder idea is an interesting one. However is it limiting in some of the manipulation that you can do with the existing simple array subcommands parameter? Currently it's quite easy to do things like dynamically alphabetically sort the subcommands array or only include some commands based on #if checks. I checked out this branch and played around with a it a bit and it wasn't obvious to me how you could achieve those kinds of things with the builder (but I'm very inexperienced with resultBuilders!)

@dickoff
Copy link

dickoff commented Jun 3, 2024

Do we think there's value in allowing for a longer description string for the group? For example how subcommand groups are described in git -h. E.g. accomplishing something like this in the test, adding a description to the header title.

BROKEN SUBCOMMANDS: These commands are currently non-functional due to XYZ
  foo                     Perform some foo
  bar                     Perform bar operations

@DougGregor
Copy link
Member Author

The CommandsBuilder idea is an interesting one. However is it limiting in some of the manipulation that you can do with the existing simple array subcommands parameter?

It shouldn't be too limiting. Result builders can support more language syntax if we'd like (e.g., the ability to do if #available and such), although I didn't add them.

I checked out this branch and played around with a it a bit and it wasn't obvious to me how you could achieve those kinds of things with the builder (but I'm very inexperienced with resultBuilders!)

I'd expect #if to work inside the builder already. If we want if, if-else, switch, and if #available to work, we can add that support to the builder by introducing the appropriate buildXYZ methods.

Do we think there's value in allowing for a longer description string for the group?

Yes, I think that's a good idea! It's easy enough to add an "abstract" that we put here.

@DougGregor
Copy link
Member Author

@swift-ci please test

@DougGregor
Copy link
Member Author

@dickoff See the latest two commits, which add an optional abstract and then extend out the result builders to handle all of the declarative syntax.

@DougGregor
Copy link
Member Author

I've opened up a discussion thread on the forums for this change: https://forums.swift.org/t/grouping-subcommands/72219

@dickoff
Copy link

dickoff commented Jun 3, 2024

@dickoff See the latest two commits, which add an optional abstract and then extend out the result builders to handle all of the declarative syntax.

New changes look great! They resolve all the questions I brought up.

@rauhul
Copy link
Contributor

rauhul commented Jun 3, 2024

I would recommend splitting out the abstract into a different PR. If we add this functionality to CommandGroups then we should probably add them OptionGroups and that feels like scope creep for this change.

Copy link
Member

@natecook1000 natecook1000 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks so much for this, @DougGregor! In addition to the notes below, could you add initializers that just accept arrays that are parallel to the builder-based initializers for CommandConfiguration and CommandGroup? That will be more flexible for projects that set up the command hierarchy out of place with the command declarations themselves.

var renderedHeader = String(describing: header).uppercased() + ":"
if let abstract = header.abstract {
renderedHeader += " "
renderedHeader += abstract.wrapped(to: screenWidth)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to add the abstract and then wrap?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahhh, probably. Thank you

Comment on lines 293 to 294
// - grouped subcommands
// - ungrouped subcommands
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this order is flipped from the actual behavior.

}

/// Describes a single subcommand or a group thereof.
public enum Subcommand: Sendable {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This name doesn't quite describe what the type represents, since a group of subcommands is not itself a subcommand. One option is CommandGroup – I'm more comfortable with a CommandGroup that just stores a single command than a Subcommand that stores a bunch of commands. But then we couldn't use that name for the other type, which it's perfect for.

Maybe just making it Subcommands is an improvement? I don't think this is a dealbreaker – do you have any other ideas for this name?

/// are `-h` and `--help`.
/// - aliases: An array of aliases for the command's name. All of the aliases
/// MUST not match the actual command name, whether that be the derived name
/// if `commandName` is not provided, or `commandName` itself if provided.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something like this:

Suggested change
/// if `commandName` is not provided, or `commandName` itself if provided.
/// if `commandName` is not provided, or `commandName` itself if provided.
/// - groupedSubcommands: A builder closure that defines the subcommands
/// and their groups for this command.

@DougGregor
Copy link
Member Author

could you add initializers that just accept arrays that are parallel to the builder-based initializers for CommandConfiguration and CommandGroup?

Hmm. If we're going to do this, I think I'd rather drop the builder-based initializers entirely from this pull request than introduce two new interfaces for the same thing. It's completely reasonable to say that the only way to get this functionality is with syntax like this:

subcommands: [
  // all of the ungrouped subcommands
],
groupedSubcommands: [
  CommandGroup(
    name: "group name",
    subcommands: [
      // subcommands in this group
    ]
  ),
  // ...
].

That avoids having to create the Subcommand type at all, and is arguably more in keeping with the current API design. It would leave the door open to a builder-based design in the future should we want it.

@natecook1000
Copy link
Member

That makes sense! I could see that fitting into a whole builder-based configuration approach (which I think we've looked at before). If we make this change, let's also make flattenedSubcommands (or allSubcommands) public API as well, since the subcommands will live in two different places.

@dickoff
Copy link

dickoff commented Jun 3, 2024

That makes sense! I could see that fitting into a whole builder-based configuration approach (which I think we've looked at before). If we make this change, let's also make flattenedSubcommands (or allSubcommands) public API as well, since the subcommands will live in two different places.

Could we not just have the existing subcommands still return all ungrouped and grouped subcommands together? Having subcommands start to only return ungrouped commands would be odd for clients I would think.

@DougGregor
Copy link
Member Author

Could we not just have the existing subcommands still return all ungrouped and grouped subcommands together?

I think we need subcommands to still return all ungrouped and grouped subcommands. I can add ungroupedSubcommands and groupedSubcommands for clients that want them separate.

…rray

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.
@DougGregor
Copy link
Member Author

@swift-ci please test

@DougGregor
Copy link
Member Author

@natecook1000 I believe I addressed all of your comments. The updated PR description contains the non-result-builder interface, which is a more straightforward extension of the existing syntax:

  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: [ ])
      ]
    )
  }

Copy link
Contributor

@rauhul rauhul left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like a great addition and UX improvement.

@@ -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]

///
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we drop this stray doc comment line?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was using the extra line to separate the "abstract" line from the "detail" line.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow I can't this morning, I made like 2 other mistakes similar, sorry about the noise

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a weird diff!

Copy link
Member

@natecook1000 natecook1000 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great, thanks again!

Comment on lines +64 to +69
/// 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]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These look great 👍🏻

@DougGregor DougGregor merged commit 516c2f8 into main Jun 4, 2024
2 checks passed
@DougGregor DougGregor deleted the command-groups-help branch June 4, 2024 21:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add ability to group commands
4 participants