Skip to content

Commit 58b0a10

Browse files
authored
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
1 parent e43ff94 commit 58b0a10

File tree

9 files changed

+380
-43
lines changed

9 files changed

+380
-43
lines changed

pkgs/args/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
## 2.8.0
2+
3+
* Allow designating a top-level command or a subcommand as a default one by
4+
passing `isDefault: true` to `addCommand` or `addSubcommand`.
5+
Default command will be selected by argument parser if no sibling command
6+
matches. This allows creating command line interfaces where both
7+
`program command` and `program command subcommand` are runnable
8+
(Fixes #103).
9+
110
## 2.7.0
211

312
* Remove sorting of the `allowedHelp` argument in usage output. Ordering will

pkgs/args/lib/command_runner.dart

Lines changed: 71 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,19 @@ class CommandRunner<T> {
3131

3232
/// A single-line template for how to invoke this executable.
3333
///
34-
/// Defaults to `"$executableName <command> arguments`". Subclasses can
35-
/// override this for a more specific template.
36-
String get invocation => '$executableName <command> [arguments]';
34+
/// Defaults to `"$executableName <command> arguments"` (if there is no
35+
/// default command) or `"$executableName [<command>] arguments"` (otherwise).
36+
///
37+
/// Subclasses can override this for a more specific template.
38+
String get invocation {
39+
var command = '<command>';
40+
41+
if (argParser.defaultCommand != null) {
42+
command = '[$command]';
43+
}
44+
45+
return '$executableName $command [arguments]';
46+
}
3747

3848
/// Generates a string displaying usage information for the executable.
3949
///
@@ -56,9 +66,10 @@ class CommandRunner<T> {
5666
);
5767
buffer.writeln(_wrap('Global options:'));
5868
buffer.writeln('${argParser.usage}\n');
59-
buffer.writeln(
60-
'${_getCommandUsage(_commands, lineLength: argParser.usageLineLength)}\n',
61-
);
69+
buffer.writeln(_getCommandUsage(_commands,
70+
lineLength: argParser.usageLineLength,
71+
defaultCommand: argParser.defaultCommand));
72+
buffer.writeln();
6273
buffer.write(_wrap(
6374
'Run "$executableName help <command>" for more information about a '
6475
'command.'));
@@ -105,12 +116,25 @@ class CommandRunner<T> {
105116
throw UsageException(message, _usageWithoutDescription);
106117

107118
/// Adds [Command] as a top-level command to this runner.
108-
void addCommand(Command<T> command) {
119+
///
120+
/// If [isDefault] is `true` then added command will be designated as a
121+
/// default one. Default command is selected if no other sibling command
122+
/// matches. Only a single leaf-command can be designated as a default.
123+
void addCommand(Command<T> command, {bool isDefault = false}) {
124+
if (isDefault && command.subcommands.isNotEmpty) {
125+
throw ArgumentError('default command must be a leaf command');
126+
}
127+
if (isDefault && argParser.defaultCommand != null) {
128+
throw StateError('default command already defined');
129+
}
109130
var names = [command.name, ...command.aliases];
110131
for (var name in names) {
111132
_commands[name] = command;
112133
argParser.addCommand(name, command.argParser);
113134
}
135+
if (isDefault) {
136+
argParser.defaultCommand = command.name;
137+
}
114138
command._runner = this;
115139
}
116140

@@ -288,9 +312,13 @@ abstract class Command<T> {
288312
parents.add(runner!.executableName);
289313

290314
var invocation = parents.reversed.join(' ');
291-
return _subcommands.isNotEmpty
292-
? '$invocation <subcommand> [arguments]'
293-
: '$invocation [arguments]';
315+
if (argParser.defaultCommand != null) {
316+
return '$invocation [<subcommand>] [arguments]';
317+
} else if (_subcommands.isNotEmpty) {
318+
return '$invocation <subcommand> [arguments]';
319+
} else {
320+
return '$invocation [arguments]';
321+
}
294322
}
295323

296324
/// The command's parent command, if this is a subcommand.
@@ -363,11 +391,10 @@ abstract class Command<T> {
363391

364392
if (_subcommands.isNotEmpty) {
365393
buffer.writeln();
366-
buffer.writeln(_getCommandUsage(
367-
_subcommands,
368-
isSubcommand: true,
369-
lineLength: length,
370-
));
394+
buffer.writeln(_getCommandUsage(_subcommands,
395+
isSubcommand: true,
396+
lineLength: length,
397+
defaultCommand: argParser.defaultCommand));
371398
}
372399

373400
buffer.writeln();
@@ -446,12 +473,26 @@ abstract class Command<T> {
446473
}
447474

448475
/// Adds [Command] as a subcommand of this.
449-
void addSubcommand(Command<T> command) {
476+
///
477+
/// If [isDefault] is `true` then added command will be designated as a
478+
/// default one. Default subcommand is selected if no other sibling subcommand
479+
/// matches. Only a single leaf-command can be designated as a default.
480+
void addSubcommand(Command<T> command, {bool isDefault = false}) {
481+
if (isDefault && command.subcommands.isNotEmpty) {
482+
throw ArgumentError('default command must be a leaf command');
483+
}
484+
if (isDefault && argParser.defaultCommand != null) {
485+
throw StateError('default command already defined');
486+
}
487+
450488
var names = [command.name, ...command.aliases];
451489
for (var name in names) {
452490
_subcommands[name] = command;
453491
argParser.addCommand(name, command.argParser);
454492
}
493+
if (isDefault) {
494+
argParser.defaultCommand = command.name;
495+
}
455496
command._parent = this;
456497
}
457498

@@ -470,8 +511,10 @@ abstract class Command<T> {
470511
///
471512
/// [isSubcommand] indicates whether the commands should be called "commands" or
472513
/// "subcommands".
514+
///
515+
/// [defaultCommand] indicate which command (if any) is designated as default.
473516
String _getCommandUsage(Map<String, Command> commands,
474-
{bool isSubcommand = false, int? lineLength}) {
517+
{bool isSubcommand = false, int? lineLength, String? defaultCommand}) {
475518
// Don't include aliases.
476519
var names =
477520
commands.keys.where((name) => !commands[name]!.aliases.contains(name));
@@ -502,7 +545,8 @@ String _getCommandUsage(Map<String, Command> commands,
502545
buffer.write(category);
503546
}
504547
for (var command in commandsByCategory[category]!) {
505-
var lines = wrapTextAsLines(command.summary,
548+
var defaultMarker = defaultCommand == command.name ? '(default) ' : '';
549+
var lines = wrapTextAsLines(defaultMarker + command.summary,
506550
start: columnStart, length: lineLength);
507551
buffer.writeln();
508552
buffer.write(' ${padRight(command.name, length)} ${lines.first}');
@@ -515,6 +559,15 @@ String _getCommandUsage(Map<String, Command> commands,
515559
}
516560
}
517561

562+
if (defaultCommand != null) {
563+
buffer.writeln();
564+
buffer.writeln();
565+
buffer.write(wrapText(
566+
'Default command ($defaultCommand) will be selected if no command'
567+
' is explicitly specified.',
568+
length: lineLength));
569+
}
570+
518571
return buffer.toString();
519572
}
520573

pkgs/args/lib/src/allow_anything_parser.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,11 @@ class AllowAnythingParser implements ArgParser {
104104

105105
@override
106106
Option? findByNameOrAlias(String name) => null;
107+
108+
@override
109+
String? get defaultCommand => null;
110+
111+
@override
112+
set defaultCommand(String? value) => throw UnsupportedError(
113+
"ArgParser.allowAnything().defaultCommand= isn't supported.");
107114
}

pkgs/args/lib/src/arg_parser.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ class ArgParser {
2525
/// The commands that have been defined for this parser.
2626
final Map<String, ArgParser> commands;
2727

28+
/// Command which will be executed by default if no command is specified.
29+
///
30+
/// When `null` it is a usage error to omit the command name.
31+
String? defaultCommand;
32+
2833
/// A list of the [Option]s in [options] intermingled with [String]
2934
/// separators.
3035
final _optionsAndSeparators = <Object>[];

pkgs/args/lib/src/parser.dart

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ class Parser {
4949
_grammar, const {}, _commandName, null, arguments, arguments);
5050
}
5151

52-
ArgResults? commandResults;
52+
({String name, ArgParser parser})? command;
5353

5454
// Parse the args.
5555
while (_args.isNotEmpty) {
@@ -61,26 +61,19 @@ class Parser {
6161

6262
// Try to parse the current argument as a command. This happens before
6363
// options so that commands can have option-like names.
64-
var command = _grammar.commands[_current];
65-
if (command != null) {
66-
_validate(_rest.isEmpty, 'Cannot specify arguments before a command.',
67-
_current);
68-
var commandName = _args.removeFirst();
69-
var commandParser = Parser(commandName, command, _args, this, _rest);
70-
71-
try {
72-
commandResults = commandParser.parse();
73-
} on ArgParserException catch (error) {
74-
throw ArgParserException(
75-
error.message,
76-
[commandName, ...error.commands],
77-
error.argumentName,
78-
error.source,
79-
error.offset);
80-
}
81-
82-
// All remaining arguments were passed to command so clear them here.
83-
_rest.clear();
64+
//
65+
// Otherwise, if there is a default command then select it before parsing
66+
// any arguments. We make exception for situations when help flag is
67+
// passed because we want `program command -h` to display help for
68+
// `command` rather than display help for the default subcommand of the
69+
// `command`.
70+
if (_grammar.commands[_current] case final parser?) {
71+
command = (name: _args.removeFirst(), parser: parser);
72+
break;
73+
} else if (_grammar.defaultCommand case final defaultCommand?
74+
when !(_current == '-h' || _current == '--help')) {
75+
command =
76+
(name: defaultCommand, parser: _grammar.commands[defaultCommand]!);
8477
break;
8578
}
8679

@@ -96,6 +89,38 @@ class Parser {
9689
_rest.add(_args.removeFirst());
9790
}
9891

92+
// If there is a default command and we did not select any other commands
93+
// and we don't have any trailing arguments then select the default
94+
// command unless user requested help.
95+
if (command == null && _rest.isEmpty && !_results.containsKey('help')) {
96+
if (_grammar.defaultCommand case final defaultCommand?) {
97+
command =
98+
(name: defaultCommand, parser: _grammar.commands[defaultCommand]!);
99+
}
100+
}
101+
102+
ArgResults? commandResults;
103+
if (command != null) {
104+
_validate(_rest.isEmpty, 'Cannot specify arguments before a command.',
105+
command.name);
106+
var commandParser =
107+
Parser(command.name, command.parser, _args, this, _rest);
108+
109+
try {
110+
commandResults = commandParser.parse();
111+
} on ArgParserException catch (error) {
112+
throw ArgParserException(
113+
error.message,
114+
[command.name, ...error.commands],
115+
error.argumentName,
116+
error.source,
117+
error.offset);
118+
}
119+
120+
// All remaining arguments were passed to command so clear them here.
121+
_rest.clear();
122+
}
123+
99124
// Check if mandatory and invoke existing callbacks.
100125
_grammar.options.forEach((name, option) {
101126
var parsedOption = _results[name];

pkgs/args/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: args
2-
version: 2.7.0
2+
version: 2.8.0
33
description: >-
44
Library for defining parsers for parsing raw command-line arguments into a set
55
of options and values using GNU and POSIX style options.

0 commit comments

Comments
 (0)