Add ability to group commands by category in usage (#202)

To enable https://github.com/flutter/flutter/issues/83706

Formatting is loosely based on what `Brew` does, and open to suggestions.  Existing tests are unchanged.

Example (all displayed commands categorized):
<img width="669" alt="Screenshot 2021-07-23 at 16 22 40" src="https://user-images.githubusercontent.com/6655696/126796093-e8652385-c3d7-4600-83b1-76852ab46ea4.png">
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 59f7a33..3c2b591 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 2.3.0
+
+* Add the ability to group commands by category in usage text.
+
 ## 2.2.0
 
 * Suggest similar commands if an unknown command is encountered, when using the
diff --git a/lib/command_runner.dart b/lib/command_runner.dart
index ff6da4a..79fa282 100644
--- a/lib/command_runner.dart
+++ b/lib/command_runner.dart
@@ -262,6 +262,12 @@
   /// This defaults to the first line of [description].
   String get summary => description.split('\n').first;
 
+  /// The command's category.
+  ///
+  /// Displayed in [parent]'s [CommandRunner.usage]. Commands with categories
+  /// will be grouped together, and displayed after commands without a category.
+  String get category => '';
+
   /// A single-line template for how to invoke this command (e.g. `"pub get
   /// `package`"`).
   String get invocation {
@@ -455,20 +461,36 @@
 
   // Show the commands alphabetically.
   names = names.toList()..sort();
+
+  // Group the commands by category.
+  var commandsByCategory = SplayTreeMap<String, List<Command>>();
+  for (var name in names) {
+    var category = commands[name]!.category;
+    commandsByCategory.putIfAbsent(category, () => []).add(commands[name]!);
+  }
+  final categories = commandsByCategory.keys.toList();
+
   var length = names.map((name) => name.length).reduce(math.max);
 
   var buffer = StringBuffer('Available ${isSubcommand ? "sub" : ""}commands:');
   var columnStart = length + 5;
-  for (var name in names) {
-    var lines = wrapTextAsLines(commands[name]!.summary,
-        start: columnStart, length: lineLength);
-    buffer.writeln();
-    buffer.write('  ${padRight(name, length)}   ${lines.first}');
-
-    for (var line in lines.skip(1)) {
+  for (var category in categories) {
+    if (category != '') {
       buffer.writeln();
-      buffer.write(' ' * columnStart);
-      buffer.write(line);
+      buffer.writeln();
+      buffer.write('$category');
+    }
+    for (var command in commandsByCategory[category]!) {
+      var lines = wrapTextAsLines(command.summary,
+          start: columnStart, length: lineLength);
+      buffer.writeln();
+      buffer.write('  ${padRight(command.name, length)}   ${lines.first}');
+
+      for (var line in lines.skip(1)) {
+        buffer.writeln();
+        buffer.write(' ' * columnStart);
+        buffer.write(line);
+      }
     }
   }
 
diff --git a/pubspec.yaml b/pubspec.yaml
index 332a9bd..b2994c7 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: args
-version: 2.2.0
+version: 2.3.0
 homepage: https://github.com/dart-lang/args
 description: >-
  Library for defining parsers for parsing raw command-line arguments into a set
diff --git a/test/command_runner_test.dart b/test/command_runner_test.dart
index 4cc6066..3f152b0 100644
--- a/test/command_runner_test.dart
+++ b/test/command_runner_test.dart
@@ -53,6 +53,101 @@
 Run "test help <command>" for more information about a command.'''));
     });
 
+    group('displays categories', () {
+      test('when some commands are categorized', () {
+        runner.addCommand(Category1Command());
+        runner.addCommand(Category2Command());
+        runner.addCommand(FooCommand());
+
+        expect(runner.usage, equals('''
+A test command runner.
+
+Usage: test <command> [arguments]
+
+Global options:
+-h, --help    Print this usage information.
+
+Available commands:
+  foo   Set a value.
+
+Displayers
+  baz   Display a value.
+
+Printers
+  bar   Print a value.
+
+Run "test help <command>" for more information about a command.'''));
+      });
+
+      test('except when all commands in a category are hidden', () {
+        runner.addCommand(Category1Command());
+        runner.addCommand(HiddenCategorizedCommand());
+
+        expect(runner.usage, equals('''
+A test command runner.
+
+Usage: test <command> [arguments]
+
+Global options:
+-h, --help    Print this usage information.
+
+Available commands:
+
+Printers
+  bar   Print a value.
+
+Run "test help <command>" for more information about a command.'''));
+      });
+
+      test('when all commands are categorized', () {
+        runner.addCommand(Category1Command());
+        runner.addCommand(Category2Command());
+
+        expect(runner.usage, equals('''
+A test command runner.
+
+Usage: test <command> [arguments]
+
+Global options:
+-h, --help    Print this usage information.
+
+Available commands:
+
+Displayers
+  baz   Display a value.
+
+Printers
+  bar   Print a value.
+
+Run "test help <command>" for more information about a command.'''));
+      });
+
+      test('when multiple commands are in a category', () {
+        runner.addCommand(Category1Command());
+        runner.addCommand(Category2Command());
+        runner.addCommand(Category2Command2());
+
+        expect(runner.usage, equals('''
+A test command runner.
+
+Usage: test <command> [arguments]
+
+Global options:
+-h, --help    Print this usage information.
+
+Available commands:
+
+Displayers
+  baz    Display a value.
+  baz2   Display another value.
+
+Printers
+  bar    Print a value.
+
+Run "test help <command>" for more information about a command.'''));
+      });
+    });
+
     test('truncates newlines in command descriptions by default', () {
       runner.addCommand(MultilineCommand());
 
@@ -109,6 +204,7 @@
     test("doesn't print hidden commands", () {
       runner
         ..addCommand(HiddenCommand())
+        ..addCommand(HiddenCategorizedCommand())
         ..addCommand(FooCommand());
 
       expect(runner.usage, equals('''
diff --git a/test/test_utils.dart b/test/test_utils.dart
index 9f2d3df..c580d71 100644
--- a/test/test_utils.dart
+++ b/test/test_utils.dart
@@ -75,6 +75,69 @@
   Future<String> run() async => 'hi';
 }
 
+class Category1Command extends Command {
+  var hasRun = false;
+
+  @override
+  final name = 'bar';
+
+  @override
+  final description = 'Print a value.';
+
+  @override
+  final category = 'Printers';
+
+  @override
+  final takesArguments = false;
+
+  @override
+  void run() {
+    hasRun = true;
+  }
+}
+
+class Category2Command extends Command {
+  var hasRun = false;
+
+  @override
+  final name = 'baz';
+
+  @override
+  final description = 'Display a value.';
+
+  @override
+  final category = 'Displayers';
+
+  @override
+  final takesArguments = false;
+
+  @override
+  void run() {
+    hasRun = true;
+  }
+}
+
+class Category2Command2 extends Command {
+  var hasRun = false;
+
+  @override
+  final name = 'baz2';
+
+  @override
+  final description = 'Display another value.';
+
+  @override
+  final category = 'Displayers';
+
+  @override
+  final takesArguments = false;
+
+  @override
+  void run() {
+    hasRun = true;
+  }
+}
+
 class MultilineCommand extends Command {
   var hasRun = false;
 
@@ -168,6 +231,30 @@
   }
 }
 
+class HiddenCategorizedCommand extends Command {
+  var hasRun = false;
+
+  @override
+  final name = 'hiddencategorized';
+
+  @override
+  final description = 'Set a value.';
+
+  @override
+  final category = 'Some category';
+
+  @override
+  final hidden = true;
+
+  @override
+  final takesArguments = false;
+
+  @override
+  void run() {
+    hasRun = true;
+  }
+}
+
 class AliasedCommand extends Command {
   var hasRun = false;