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;