Make it possible to add default subcommand (#925)
Make it possible to add default subcommand (designated by an empty
name) for a branch command: default subcommand will be run when no other
subcommand is selected. This allows creating command line interfaces
where both `program command` and `program command subcommand` are
runnable.
Fixes #103
diff --git a/pkgs/args/CHANGELOG.md b/pkgs/args/CHANGELOG.md
index f712877..54985ff 100644
--- a/pkgs/args/CHANGELOG.md
+++ b/pkgs/args/CHANGELOG.md
@@ -1,3 +1,12 @@
+## 2.8.0
+
+* Allow designating a top-level command or a subcommand as a default one by
+ passing `isDefault: true` to `addCommand` or `addSubcommand`.
+ Default command will be selected by argument parser if no sibling command
+ matches. This allows creating command line interfaces where both
+ `program command` and `program command subcommand` are runnable
+ (Fixes #103).
+
## 2.7.0
* Remove sorting of the `allowedHelp` argument in usage output. Ordering will
diff --git a/pkgs/args/lib/command_runner.dart b/pkgs/args/lib/command_runner.dart
index e72a08d..2f878c5 100644
--- a/pkgs/args/lib/command_runner.dart
+++ b/pkgs/args/lib/command_runner.dart
@@ -31,9 +31,19 @@
/// A single-line template for how to invoke this executable.
///
- /// Defaults to `"$executableName <command> arguments`". Subclasses can
- /// override this for a more specific template.
- String get invocation => '$executableName <command> [arguments]';
+ /// Defaults to `"$executableName <command> arguments"` (if there is no
+ /// default command) or `"$executableName [<command>] arguments"` (otherwise).
+ ///
+ /// Subclasses can override this for a more specific template.
+ String get invocation {
+ var command = '<command>';
+
+ if (argParser.defaultCommand != null) {
+ command = '[$command]';
+ }
+
+ return '$executableName $command [arguments]';
+ }
/// Generates a string displaying usage information for the executable.
///
@@ -56,9 +66,10 @@
);
buffer.writeln(_wrap('Global options:'));
buffer.writeln('${argParser.usage}\n');
- buffer.writeln(
- '${_getCommandUsage(_commands, lineLength: argParser.usageLineLength)}\n',
- );
+ buffer.writeln(_getCommandUsage(_commands,
+ lineLength: argParser.usageLineLength,
+ defaultCommand: argParser.defaultCommand));
+ buffer.writeln();
buffer.write(_wrap(
'Run "$executableName help <command>" for more information about a '
'command.'));
@@ -105,12 +116,25 @@
throw UsageException(message, _usageWithoutDescription);
/// Adds [Command] as a top-level command to this runner.
- void addCommand(Command<T> command) {
+ ///
+ /// If [isDefault] is `true` then added command will be designated as a
+ /// default one. Default command is selected if no other sibling command
+ /// matches. Only a single leaf-command can be designated as a default.
+ void addCommand(Command<T> command, {bool isDefault = false}) {
+ if (isDefault && command.subcommands.isNotEmpty) {
+ throw ArgumentError('default command must be a leaf command');
+ }
+ if (isDefault && argParser.defaultCommand != null) {
+ throw StateError('default command already defined');
+ }
var names = [command.name, ...command.aliases];
for (var name in names) {
_commands[name] = command;
argParser.addCommand(name, command.argParser);
}
+ if (isDefault) {
+ argParser.defaultCommand = command.name;
+ }
command._runner = this;
}
@@ -288,9 +312,13 @@
parents.add(runner!.executableName);
var invocation = parents.reversed.join(' ');
- return _subcommands.isNotEmpty
- ? '$invocation <subcommand> [arguments]'
- : '$invocation [arguments]';
+ if (argParser.defaultCommand != null) {
+ return '$invocation [<subcommand>] [arguments]';
+ } else if (_subcommands.isNotEmpty) {
+ return '$invocation <subcommand> [arguments]';
+ } else {
+ return '$invocation [arguments]';
+ }
}
/// The command's parent command, if this is a subcommand.
@@ -363,11 +391,10 @@
if (_subcommands.isNotEmpty) {
buffer.writeln();
- buffer.writeln(_getCommandUsage(
- _subcommands,
- isSubcommand: true,
- lineLength: length,
- ));
+ buffer.writeln(_getCommandUsage(_subcommands,
+ isSubcommand: true,
+ lineLength: length,
+ defaultCommand: argParser.defaultCommand));
}
buffer.writeln();
@@ -446,12 +473,26 @@
}
/// Adds [Command] as a subcommand of this.
- void addSubcommand(Command<T> command) {
+ ///
+ /// If [isDefault] is `true` then added command will be designated as a
+ /// default one. Default subcommand is selected if no other sibling subcommand
+ /// matches. Only a single leaf-command can be designated as a default.
+ void addSubcommand(Command<T> command, {bool isDefault = false}) {
+ if (isDefault && command.subcommands.isNotEmpty) {
+ throw ArgumentError('default command must be a leaf command');
+ }
+ if (isDefault && argParser.defaultCommand != null) {
+ throw StateError('default command already defined');
+ }
+
var names = [command.name, ...command.aliases];
for (var name in names) {
_subcommands[name] = command;
argParser.addCommand(name, command.argParser);
}
+ if (isDefault) {
+ argParser.defaultCommand = command.name;
+ }
command._parent = this;
}
@@ -470,8 +511,10 @@
///
/// [isSubcommand] indicates whether the commands should be called "commands" or
/// "subcommands".
+///
+/// [defaultCommand] indicate which command (if any) is designated as default.
String _getCommandUsage(Map<String, Command> commands,
- {bool isSubcommand = false, int? lineLength}) {
+ {bool isSubcommand = false, int? lineLength, String? defaultCommand}) {
// Don't include aliases.
var names =
commands.keys.where((name) => !commands[name]!.aliases.contains(name));
@@ -502,7 +545,8 @@
buffer.write(category);
}
for (var command in commandsByCategory[category]!) {
- var lines = wrapTextAsLines(command.summary,
+ var defaultMarker = defaultCommand == command.name ? '(default) ' : '';
+ var lines = wrapTextAsLines(defaultMarker + command.summary,
start: columnStart, length: lineLength);
buffer.writeln();
buffer.write(' ${padRight(command.name, length)} ${lines.first}');
@@ -515,6 +559,15 @@
}
}
+ if (defaultCommand != null) {
+ buffer.writeln();
+ buffer.writeln();
+ buffer.write(wrapText(
+ 'Default command ($defaultCommand) will be selected if no command'
+ ' is explicitly specified.',
+ length: lineLength));
+ }
+
return buffer.toString();
}
diff --git a/pkgs/args/lib/src/allow_anything_parser.dart b/pkgs/args/lib/src/allow_anything_parser.dart
index 69472b3..20d77db 100644
--- a/pkgs/args/lib/src/allow_anything_parser.dart
+++ b/pkgs/args/lib/src/allow_anything_parser.dart
@@ -104,4 +104,11 @@
@override
Option? findByNameOrAlias(String name) => null;
+
+ @override
+ String? get defaultCommand => null;
+
+ @override
+ set defaultCommand(String? value) => throw UnsupportedError(
+ "ArgParser.allowAnything().defaultCommand= isn't supported.");
}
diff --git a/pkgs/args/lib/src/arg_parser.dart b/pkgs/args/lib/src/arg_parser.dart
index 37041d7..22bc2b4 100644
--- a/pkgs/args/lib/src/arg_parser.dart
+++ b/pkgs/args/lib/src/arg_parser.dart
@@ -25,6 +25,11 @@
/// The commands that have been defined for this parser.
final Map<String, ArgParser> commands;
+ /// Command which will be executed by default if no command is specified.
+ ///
+ /// When `null` it is a usage error to omit the command name.
+ String? defaultCommand;
+
/// A list of the [Option]s in [options] intermingled with [String]
/// separators.
final _optionsAndSeparators = <Object>[];
diff --git a/pkgs/args/lib/src/parser.dart b/pkgs/args/lib/src/parser.dart
index 660e56d..9aca0f2 100644
--- a/pkgs/args/lib/src/parser.dart
+++ b/pkgs/args/lib/src/parser.dart
@@ -49,7 +49,7 @@
_grammar, const {}, _commandName, null, arguments, arguments);
}
- ArgResults? commandResults;
+ ({String name, ArgParser parser})? command;
// Parse the args.
while (_args.isNotEmpty) {
@@ -61,26 +61,19 @@
// Try to parse the current argument as a command. This happens before
// options so that commands can have option-like names.
- var command = _grammar.commands[_current];
- if (command != null) {
- _validate(_rest.isEmpty, 'Cannot specify arguments before a command.',
- _current);
- var commandName = _args.removeFirst();
- var commandParser = Parser(commandName, command, _args, this, _rest);
-
- try {
- commandResults = commandParser.parse();
- } on ArgParserException catch (error) {
- throw ArgParserException(
- error.message,
- [commandName, ...error.commands],
- error.argumentName,
- error.source,
- error.offset);
- }
-
- // All remaining arguments were passed to command so clear them here.
- _rest.clear();
+ //
+ // Otherwise, if there is a default command then select it before parsing
+ // any arguments. We make exception for situations when help flag is
+ // passed because we want `program command -h` to display help for
+ // `command` rather than display help for the default subcommand of the
+ // `command`.
+ if (_grammar.commands[_current] case final parser?) {
+ command = (name: _args.removeFirst(), parser: parser);
+ break;
+ } else if (_grammar.defaultCommand case final defaultCommand?
+ when !(_current == '-h' || _current == '--help')) {
+ command =
+ (name: defaultCommand, parser: _grammar.commands[defaultCommand]!);
break;
}
@@ -96,6 +89,38 @@
_rest.add(_args.removeFirst());
}
+ // If there is a default command and we did not select any other commands
+ // and we don't have any trailing arguments then select the default
+ // command unless user requested help.
+ if (command == null && _rest.isEmpty && !_results.containsKey('help')) {
+ if (_grammar.defaultCommand case final defaultCommand?) {
+ command =
+ (name: defaultCommand, parser: _grammar.commands[defaultCommand]!);
+ }
+ }
+
+ ArgResults? commandResults;
+ if (command != null) {
+ _validate(_rest.isEmpty, 'Cannot specify arguments before a command.',
+ command.name);
+ var commandParser =
+ Parser(command.name, command.parser, _args, this, _rest);
+
+ try {
+ commandResults = commandParser.parse();
+ } on ArgParserException catch (error) {
+ throw ArgParserException(
+ error.message,
+ [command.name, ...error.commands],
+ error.argumentName,
+ error.source,
+ error.offset);
+ }
+
+ // All remaining arguments were passed to command so clear them here.
+ _rest.clear();
+ }
+
// Check if mandatory and invoke existing callbacks.
_grammar.options.forEach((name, option) {
var parsedOption = _results[name];
diff --git a/pkgs/args/pubspec.yaml b/pkgs/args/pubspec.yaml
index d0a54e0..d65ca0b 100644
--- a/pkgs/args/pubspec.yaml
+++ b/pkgs/args/pubspec.yaml
@@ -1,5 +1,5 @@
name: args
-version: 2.7.0
+version: 2.8.0
description: >-
Library for defining parsers for parsing raw command-line arguments into a set
of options and values using GNU and POSIX style options.
diff --git a/pkgs/args/test/command_runner_test.dart b/pkgs/args/test/command_runner_test.dart
index b9fde8a..b3ac7b8 100644
--- a/pkgs/args/test/command_runner_test.dart
+++ b/pkgs/args/test/command_runner_test.dart
@@ -24,8 +24,15 @@
runner = CommandRunner('test', 'A test command runner.');
});
- test('.invocation has a sane default', () {
- expect(runner.invocation, equals('test <command> [arguments]'));
+ group('.invocation has a sensible default', () {
+ test('without default command', () {
+ expect(runner.invocation, equals('test <command> [arguments]'));
+ });
+
+ test('with default command', () {
+ runner.addCommand(FooCommand(), isDefault: true);
+ expect(runner.invocation, equals('test [<command>] [arguments]'));
+ });
});
group('.usage', () {
@@ -256,6 +263,27 @@
Run "name help <command>" for more
information about a command.'''));
});
+
+ test('contains default command', () {
+ runner.addCommand(FooCommand());
+ runner.addCommand(BarCommand(), isDefault: true);
+
+ expect(runner.usage, equals('''
+A test command runner.
+
+Usage: test [<command>] [arguments]
+
+Global options:
+-h, --help Print this usage information.
+
+Available commands:
+ bar (default) Set another value.
+ foo Set a value.
+
+Default command (bar) will be selected if no command is explicitly specified.
+
+Run "test help <command>" for more information about a command.'''));
+ });
});
test('usageException splits up the message and usage', () {
@@ -263,6 +291,21 @@
throwsUsageException('message', _defaultUsage));
});
+ group('.addCommand', () {
+ test('only one command can be default', () {
+ runner.addCommand(FooCommand(), isDefault: true);
+ expect(() => runner.addCommand(BarCommand(), isDefault: true),
+ throwsStateError);
+ });
+
+ test('only leaf command can be default', () {
+ expect(
+ () => runner.addCommand(BarCommand()..addSubcommand(FooCommand()),
+ isDefault: true),
+ throwsArgumentError);
+ });
+ });
+
group('run()', () {
test('runs a command', () {
var command = FooCommand();
@@ -741,6 +784,141 @@
expect(await runner.run([subcommand.name, '--mandatory-option', 'foo']),
'foo');
});
+
+ test('default command runs', () {
+ final defaultSubcommand = FooCommand();
+ runner.addCommand(defaultSubcommand, isDefault: true);
+
+ expect(
+ runner.run([]).then((_) {
+ expect(defaultSubcommand.hasRun, isTrue);
+ }),
+ completes);
+ });
+
+ test('default subcommand runs', () {
+ final defaultSubcommand = FooCommand();
+ final command = FooCommand()
+ ..addSubcommand(AsyncCommand())
+ ..addSubcommand(defaultSubcommand, isDefault: true);
+ runner.addCommand(command);
+
+ expect(
+ runner.run(['foo']).then((_) {
+ expect(defaultSubcommand.hasRun, isTrue);
+ }),
+ completes);
+ });
+
+ test('default subcommand parses flags', () {
+ final defaultSubcommand = BarCommand();
+ final command = FooCommand()
+ ..addSubcommand(AsyncCommand())
+ ..addSubcommand(defaultSubcommand, isDefault: true);
+ runner.addCommand(command);
+
+ expect(
+ runner.run(['foo', '--flag']).then((_) {
+ expect(defaultSubcommand.hasRun, isTrue);
+ expect(defaultSubcommand.argResults?.flag('flag'), isTrue);
+ }),
+ completes);
+ });
+
+ test('named subcommand has precedence over default', () {
+ final defaultSubcommand = BarCommand();
+ final asyncCommand = AsyncCommand();
+ final command = FooCommand()
+ ..addSubcommand(asyncCommand)
+ ..addSubcommand(defaultSubcommand, isDefault: true);
+ runner.addCommand(command);
+
+ expect(
+ runner.run(['foo', 'async']).then((_) {
+ expect(defaultSubcommand.hasRun, isFalse);
+ expect(asyncCommand.hasRun, isTrue);
+ }),
+ completes);
+ });
+
+ test('default command throws meaningful error for unexpected argument', () {
+ final defaultSubcommand = BarCommand();
+ runner.addCommand(defaultSubcommand, isDefault: true);
+
+ expect(
+ runner.run(['foo']),
+ throwsUsageException(
+ '''Command "bar" does not take any arguments.''', anything));
+ });
+
+ test('default subcommand throws meaningful error for unexpected argument',
+ () {
+ final defaultSubcommand = BarCommand();
+ final asyncCommand = AsyncCommand();
+ final command = FooCommand()
+ ..addSubcommand(asyncCommand)
+ ..addSubcommand(defaultSubcommand, isDefault: true);
+ runner.addCommand(command);
+
+ expect(
+ runner.run(['foo', 'baz']),
+ throwsUsageException(
+ '''Command "bar" does not take any arguments.''', anything));
+ });
+
+ test('help flag has precedence over default command', () {
+ final defaultCommand = BarCommand();
+ runner.addCommand(defaultCommand, isDefault: true);
+
+ expect(
+ () => runner.run(['-h']).then((_) {
+ expect(defaultCommand.hasRun, isFalse);
+ }),
+ prints('''
+A test command runner.
+
+Usage: test [<command>] [arguments]
+
+Global options:
+-h, --help Print this usage information.
+
+Available commands:
+ bar (default) Set another value.
+
+Default command (bar) will be selected if no command is explicitly specified.
+
+Run "test help <command>" for more information about a command.
+'''));
+ });
+
+ test('help flag has precedence over default subcommand', () {
+ final defaultSubcommand = BarCommand();
+ final asyncCommand = AsyncCommand();
+ final command = FooCommand()
+ ..addSubcommand(asyncCommand)
+ ..addSubcommand(defaultSubcommand, isDefault: true);
+ runner.addCommand(command);
+
+ expect(
+ () => runner.run(['foo', '-h']).then((_) {
+ expect(defaultSubcommand.hasRun, isFalse);
+ expect(asyncCommand.hasRun, isFalse);
+ }),
+ prints('''
+Set a value.
+
+Usage: test foo [<subcommand>] [arguments]
+-h, --help Print this usage information.
+
+Available subcommands:
+ async Set a value asynchronously.
+ bar (default) Set another value.
+
+Default command (bar) will be selected if no command is explicitly specified.
+
+Run "test help" to see global options.
+'''));
+ });
}
class _MandatoryOptionCommand extends Command {
diff --git a/pkgs/args/test/command_test.dart b/pkgs/args/test/command_test.dart
index 555cc8d..619bded 100644
--- a/pkgs/args/test/command_test.dart
+++ b/pkgs/args/test/command_test.dart
@@ -16,7 +16,7 @@
CommandRunner<void>('test', 'A test command runner.').addCommand(foo);
});
- group('.invocation has a sane default', () {
+ group('.invocation has a sensible default', () {
test('without subcommands', () {
expect(foo.invocation, equals('test foo [arguments]'));
});
@@ -26,6 +26,11 @@
expect(foo.invocation, equals('test foo <subcommand> [arguments]'));
});
+ test('with default subcommand', () {
+ foo.addSubcommand(AsyncCommand(), isDefault: true);
+ expect(foo.invocation, equals('test foo [<subcommand>] [arguments]'));
+ });
+
test('for a subcommand', () {
var async = AsyncCommand();
foo.addSubcommand(async);
@@ -140,6 +145,24 @@
Run "longtest help" to see global
options.'''));
});
+
+ test('prints default subcommand', () {
+ foo.addSubcommand(BarCommand(), isDefault: true);
+ foo.addSubcommand(AsyncCommand());
+ expect(foo.usage, equals('''
+Set a value.
+
+Usage: test foo [<subcommand>] [arguments]
+-h, --help Print this usage information.
+
+Available subcommands:
+ async Set a value asynchronously.
+ bar (default) Set another value.
+
+Default command (bar) will be selected if no command is explicitly specified.
+
+Run "test help" to see global options.'''));
+ });
});
test('usageException splits up the message and usage', () {
@@ -155,4 +178,19 @@
foo.addSubcommand(HiddenCommand());
expect(foo.hidden, isTrue);
});
+
+ group('.addSubcommand', () {
+ test('only one subcommand can be default', () {
+ foo.addSubcommand(BarCommand(), isDefault: true);
+ expect(() => foo.addSubcommand(AsyncCommand(), isDefault: true),
+ throwsStateError);
+ });
+
+ test('only leaf subcommand can be default', () {
+ expect(
+ () => foo.addSubcommand(BarCommand()..addSubcommand(FooCommand()),
+ isDefault: true),
+ throwsArgumentError);
+ });
+ });
}
diff --git a/pkgs/args/test/test_utils.dart b/pkgs/args/test/test_utils.dart
index f7d8b8a..340e52b 100644
--- a/pkgs/args/test/test_utils.dart
+++ b/pkgs/args/test/test_utils.dart
@@ -45,6 +45,28 @@
}
}
+class BarCommand extends Command {
+ bool hasRun = false;
+
+ @override
+ final name = 'bar';
+
+ @override
+ final description = 'Set another value.';
+
+ @override
+ final takesArguments = false;
+
+ BarCommand() {
+ argParser.addFlag('flag', help: 'Some flag');
+ }
+
+ @override
+ void run() {
+ hasRun = true;
+ }
+}
+
class ValueCommand extends Command<int> {
@override
final name = 'foo';