diff --git a/example/main.dart b/example/main.dart index 7669ec5..d8c724d 100644 --- a/example/main.dart +++ b/example/main.dart @@ -1,16 +1,120 @@ +import 'dart:async' show FutureOr; +import 'dart:io' show exit; + +import 'package:args/command_runner.dart'; import 'package:cli_tools/cli_tools.dart'; +import 'package:cli_tools/config.dart'; + +void main(List args) async { + var commandRunner = BetterCommandRunner( + 'example', + 'Example CLI command', + globalOptions: [ + StandardGlobalOption.quiet, + StandardGlobalOption.verbose, + ], + ); + commandRunner.addCommand(TimeSeriesCommand()); + + try { + await commandRunner.run(args); + } on UsageException catch (e) { + print(e); + exit(1); + } -void main() async { /// Simple example of using the [StdOutLogger] class. - var logger = StdOutLogger(LogLevel.info); + final LogLevel logLevel; + if (commandRunner.globalConfiguration.value(StandardGlobalOption.verbose)) { + logLevel = LogLevel.debug; + } else if (commandRunner.globalConfiguration + .value(StandardGlobalOption.quiet)) { + logLevel = LogLevel.error; + } else { + logLevel = LogLevel.info; + } + var logger = StdOutLogger(logLevel); logger.info('An info message'); logger.error('An error message'); logger.debug( - 'A debug message that will not be shown because log level is info', + 'A debug message that will not be shown unless --verbose is set', ); await logger.progress( 'A progress message', () async => Future.delayed(const Duration(seconds: 3), () => true), ); } + +/// Options are defineable as enums as well as regular lists. +/// +/// The enum approach is more distinct and type safe. +/// The list approach is more dynamic and permits non-const initialization. +enum TimeSeriesOption implements OptionDefinition { + until(DateTimeOption( + argName: 'until', + envName: 'SERIES_UNTIL', // can also be specified as environment variable + fromDefault: _defaultUntil, + helpText: 'The end timestamp of the series', + )), + length(IntOption( + argName: 'length', + argAbbrev: 'l', + argPos: 0, // can also be specified as positional argument + helpText: 'The number of elements in the series', + min: 1, + max: 100, + group: _granularityGroup, + )), + interval(DurationOption( + argName: 'interval', + argAbbrev: 'i', + helpText: 'The interval between the series elements', + min: Duration(seconds: 1), + max: Duration(days: 1), + group: _granularityGroup, + )); + + const TimeSeriesOption(this.option); + + @override + final ConfigOptionBase option; +} + +/// Exactly one of the options in this group must be set. +const _granularityGroup = MutuallyExclusive( + 'Granularity', + mode: MutuallyExclusiveMode.mandatory, +); + +/// A function can be used as a const initializer. +DateTime _defaultUntil() => DateTime.now().add(const Duration(days: 1)); + +class TimeSeriesCommand extends BetterCommand { + TimeSeriesCommand() : super(options: TimeSeriesOption.values); + + @override + String get name => 'series'; + + @override + String get description => 'Generate a series of time stamps'; + + @override + FutureOr? runWithConfig(Configuration commandConfig) { + var start = DateTime.now(); + var until = commandConfig.value(TimeSeriesOption.until); + + // exactly one of these options is set + var length = commandConfig.optionalValue(TimeSeriesOption.length); + var interval = commandConfig.optionalValue(TimeSeriesOption.interval); + interval ??= (until.difference(start) ~/ length!); + if (interval < const Duration(milliseconds: 1)) { + interval = const Duration(milliseconds: 1); + } + + while (start.isBefore(until)) { + print(start); + start = start.add(interval); + } + } +} diff --git a/lib/src/better_command_runner/better_command.dart b/lib/src/better_command_runner/better_command.dart index 8101926..a4d71b6 100644 --- a/lib/src/better_command_runner/better_command.dart +++ b/lib/src/better_command_runner/better_command.dart @@ -8,11 +8,13 @@ import 'package:cli_tools/config.dart'; import 'config_resolver.dart'; abstract class BetterCommand extends Command { + static const _defaultMessageOutput = MessageOutput(usageLogger: print); + final MessageOutput? _messageOutput; final ArgParser _argParser; /// The configuration resolver for this command. - final ConfigResolver _configResolver; + ConfigResolver? _configResolver; /// The option definitions for this command. final List options; @@ -21,9 +23,12 @@ abstract class BetterCommand extends Command { /// /// - [messageOutput] is an optional [MessageOutput] object used to pass specific log messages. /// - [wrapTextColumn] is the column width for wrapping text in the command line interface. - /// - [options] is an optional list of options. + /// - [options] is a list of options, empty by default. /// - [configResolver] is an optional custom [ConfigResolver] implementation. /// + /// [configResolver] and [messageOutput] are optional and will default to the + /// values of the command runner (if any). + /// /// To define a bespoke set of options, it is recommended to define /// a proper options enum. It can included any of the default options /// as well as any custom options. Example: @@ -47,22 +52,46 @@ abstract class BetterCommand extends Command { /// If [configResolver] is not provided then [DefaultConfigResolver] will be used, /// which uses the command line arguments and environment variables as input sources. BetterCommand({ - MessageOutput? messageOutput, + MessageOutput? messageOutput = _defaultMessageOutput, int? wrapTextColumn, this.options = const [], - final ConfigResolver? configResolver, + ConfigResolver? configResolver, }) : _messageOutput = messageOutput, _argParser = ArgParser(usageLineLength: wrapTextColumn), - _configResolver = configResolver ?? DefaultConfigResolver() { + _configResolver = configResolver { prepareOptionsForParsing(options, argParser); } + MessageOutput? get messageOutput { + if (_messageOutput != _defaultMessageOutput) { + return _messageOutput; + } + if (runner case BetterCommandRunner runner) { + return runner.messageOutput; + } + return _messageOutput; + } + + ConfigResolver get configResolver { + if (runner case BetterCommandRunner runner) { + return runner.configResolver; + } + return _configResolver ??= DefaultConfigResolver(); + } + + @override + BetterCommand? get parent => super.parent as BetterCommand?; + + @override + BetterCommandRunner? get runner => + super.runner as BetterCommandRunner?; + @override ArgParser get argParser => _argParser; @override void printUsage() { - _messageOutput?.logUsage(usage); + messageOutput?.logUsage(usage); } /// Runs this command. @@ -86,7 +115,7 @@ abstract class BetterCommand extends Command { /// This method can be overridden to change the configuration resolution /// or error handling behavior. Configuration resolveConfiguration(ArgResults? argResults) { - final config = _configResolver.resolveConfiguration( + final config = configResolver.resolveConfiguration( options: options, argResults: argResults, ); diff --git a/lib/src/better_command_runner/better_command_runner.dart b/lib/src/better_command_runner/better_command_runner.dart index 58d353f..d72bd71 100644 --- a/lib/src/better_command_runner/better_command_runner.dart +++ b/lib/src/better_command_runner/better_command_runner.dart @@ -12,26 +12,24 @@ typedef OnBeforeRunCommand = Future Function(BetterCommandRunner runner); /// /// It is valid to not provide a function in order to not pass that output. final class MessageOutput { - final void Function(UsageException exception)? _logUsageException; + final void Function(String usage)? usageLogger; + final void Function(UsageException exception)? usageExceptionLogger; - final void Function(String usage)? _logUsage; - - MessageOutput({ - void Function(UsageException exception)? logUsageException, - void Function(String usage)? logUsage, - }) : _logUsageException = logUsageException, - _logUsage = logUsage; + const MessageOutput({ + this.usageLogger, + this.usageExceptionLogger, + }); /// Logs a usage exception. /// If the function has not been provided then nothing will happen. void logUsageException(UsageException exception) { - _logUsageException?.call(exception); + usageExceptionLogger?.call(exception); } /// Logs a usage message. /// If the function has not been provided then nothing will happen. void logUsage(String usage) { - _logUsage?.call(usage); + usageLogger?.call(usage); } } @@ -74,7 +72,6 @@ class BetterCommandRunner /// The gloabl option definitions. late final List _globalOptions; - /// The resolver for the global configuration. final ConfigResolver _configResolver; Configuration? _globalConfiguration; @@ -106,6 +103,16 @@ class BetterCommandRunner /// - [globalOptions] is an optional list of global options. /// - [configResolver] is an optional custom [ConfigResolver] implementation. /// + /// ## Message Output + /// + /// The [MessageOutput] object is used to control how specific log messages + /// are output within this library. + /// By default regular (non-error) usage is printed to the console, + /// while UsageExceptions not printed by this library and simply + /// propagated to the caller, i.e. the same behavior as the `args` package. + /// + /// ## Global Options + /// /// If [globalOptions] is not provided then the default global options will be used. /// If no global options are desired then an empty list can be provided. /// @@ -138,7 +145,7 @@ class BetterCommandRunner super.executableName, super.description, { super.suggestionDistanceLimit, - MessageOutput? messageOutput, + MessageOutput? messageOutput = const MessageOutput(usageLogger: print), SetLogLevel? setLogLevel, OnBeforeRunCommand? onBeforeRunCommand, OnAnalyticsEvent? onAnalyticsEvent, @@ -155,17 +162,28 @@ class BetterCommandRunner ) { if (globalOptions != null) { _globalOptions = globalOptions; - } else if (_onAnalyticsEvent != null) { - _globalOptions = BasicGlobalOption.values as List; - } else { - _globalOptions = [ - BasicGlobalOption.quiet as O, - BasicGlobalOption.verbose as O, + } else if (O == OptionDefinition || O == StandardGlobalOption) { + _globalOptions = [ + StandardGlobalOption.quiet as O, + StandardGlobalOption.verbose as O, + if (_onAnalyticsEvent != null) StandardGlobalOption.analytics as O, ]; + } else { + throw ArgumentError( + 'globalOptions not provided and O is not assignable from StandardGlobalOption: $O', + ); } prepareOptionsForParsing(_globalOptions, argParser); } + /// The [MessageOutput] for the command runner. + /// It is also used for the commands unless they have their own. + MessageOutput? get messageOutput => _messageOutput; + + /// The configuration resolver used for the global configuration. + /// It is also used for the command configurations unless they have their own. + ConfigResolver get configResolver => _configResolver; + /// Adds a list of commands to the command runner. void addCommands(List> commands) { for (var command in commands) { @@ -176,21 +194,31 @@ class BetterCommandRunner /// Checks if analytics is enabled. bool analyticsEnabled() => _onAnalyticsEvent != null; - /// Parses the command line arguments and returns the result. + /// Parses [args] and invokes [Command.run] on the chosen command. /// - /// This method overrides the [CommandRunner.parse] method to resolve the - /// global configuration before returning the result. + /// This always returns a [Future] in case the command is asynchronous. The + /// [Future] will throw a [UsageException] if [args] was invalid. /// - /// If this method is overridden, the caller is responsible for - /// ensuring the global configuration is set, see [globalConfiguration]. + /// This overrides the [CommandRunner.run] method in order to resolve the + /// global configuration before invoking [runCommand]. + /// If this method is overridden, the overriding method must ensure that + /// the global configuration is set, see [globalConfiguration]. + @override + Future run(Iterable args) { + return Future.sync(() { + var argResults = parse(args); + globalConfiguration = resolveConfiguration(argResults); + return runCommand(argResults); + }); + } + + /// Parses the command line arguments and returns the result. @override ArgResults parse(Iterable args) { try { - var argResults = super.parse(args); - globalConfiguration = resolveConfiguration(argResults); - return argResults; + return super.parse(args); } on UsageException catch (e) { - _messageOutput?.logUsageException(e); + messageOutput?.logUsageException(e); _onAnalyticsEvent?.call(BetterCommandRunnerAnalyticsEvents.invalid); rethrow; } @@ -198,7 +226,7 @@ class BetterCommandRunner @override void printUsage() { - _messageOutput?.logUsage(usage); + messageOutput?.logUsage(usage); } /// Runs the command specified by [topLevelResults]. @@ -257,9 +285,9 @@ class BetterCommandRunner await _onBeforeRunCommand?.call(this); try { - return super.runCommand(topLevelResults); + return await super.runCommand(topLevelResults); } on UsageException catch (e) { - _messageOutput?.logUsageException(e); + messageOutput?.logUsageException(e); _onAnalyticsEvent?.call(BetterCommandRunnerAnalyticsEvents.invalid); rethrow; } @@ -276,6 +304,7 @@ class BetterCommandRunner final config = _configResolver.resolveConfiguration( options: _globalOptions, argResults: argResults, + ignoreUnexpectedPositionalArgs: true, ); if (config.errors.isNotEmpty) { @@ -336,12 +365,12 @@ abstract class BetterCommandRunnerFlags { ); } -enum BasicGlobalOption implements OptionDefinition { +enum StandardGlobalOption implements OptionDefinition { quiet(BetterCommandRunnerFlags.quietOption), verbose(BetterCommandRunnerFlags.verboseOption), analytics(BetterCommandRunnerFlags.analyticsOption); - const BasicGlobalOption(this.option); + const StandardGlobalOption(this.option); @override final ConfigOptionBase option; diff --git a/lib/src/better_command_runner/config_resolver.dart b/lib/src/better_command_runner/config_resolver.dart index c203330..5ae57bb 100644 --- a/lib/src/better_command_runner/config_resolver.dart +++ b/lib/src/better_command_runner/config_resolver.dart @@ -11,11 +11,15 @@ import 'package:cli_tools/config.dart' show Configuration, OptionDefinition; /// The purpose of this class is to delegate the configuration resolution /// in BetterCommandRunner and BetterCommand to a separate object /// they can be composed with. +/// +/// If invoked from global command runner or a command that has +/// subcommands, set [ignoreUnexpectedPositionalArgs] to true. abstract interface class ConfigResolver { /// {@macro config_resolver} Configuration resolveConfiguration({ required Iterable options, ArgResults? argResults, + bool ignoreUnexpectedPositionalArgs = false, }); } @@ -32,11 +36,13 @@ class DefaultConfigResolver Configuration resolveConfiguration({ required Iterable options, ArgResults? argResults, + bool ignoreUnexpectedPositionalArgs = false, }) { return Configuration.resolve( options: options, argResults: argResults, env: _env, + ignoreUnexpectedPositionalArgs: ignoreUnexpectedPositionalArgs, ); } } diff --git a/lib/src/config/configuration.dart b/lib/src/config/configuration.dart index bcf912f..413e5fa 100644 --- a/lib/src/config/configuration.dart +++ b/lib/src/config/configuration.dart @@ -6,7 +6,10 @@ import 'package:meta/meta.dart'; import 'option_resolution.dart'; import 'source_type.dart'; -/// Common interface to enable same treatment for [ConfigOptionBase] and option enums. +/// Common interface to enable same treatment for [ConfigOptionBase] +/// and option enums. +/// +/// [V] is the type of the value this option provides. abstract class OptionDefinition { ConfigOptionBase get option; } @@ -52,7 +55,8 @@ class OptionGroup { int get hashCode => name.hashCode; } -/// A [ValueParser] converts a source string value to the specific option value type. +/// A [ValueParser] converts a source string value to the specific option +/// value type. /// /// {@template value_parser} /// Must throw a [FormatException] with an appropriate message @@ -85,7 +89,8 @@ abstract class ValueParser { /// /// ### Typed values, parsing, and validation /// -/// Option values are typed, and parsed using the [ValueParser]. +/// [V] is the type of the value this option provides. +/// Option values are parsed to this type using the [ValueParser]. /// Subclasses of [ConfigOptionBase] may also override [validateValue] /// to perform additional validation such as range checking. /// diff --git a/pubspec.yaml b/pubspec.yaml index 5b588df..336097e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,7 +19,7 @@ dependencies: ci: ^0.1.0 collection: ^1.18.0 http: '>=0.13.0 <2.0.0' - meta: ^1.16.0 + meta: ^1.11.0 path: ^1.8.2 pub_api_client: '>=2.4.0 <4.0.0' pub_semver: ^2.1.4 diff --git a/test/better_command_runner/analytics_test.dart b/test/better_command_runner/analytics_test.dart index bfd5125..da4523e 100644 --- a/test/better_command_runner/analytics_test.dart +++ b/test/better_command_runner/analytics_test.dart @@ -59,6 +59,7 @@ void main() { 'test', 'this is a test cli', onAnalyticsEvent: null, + messageOutput: const MessageOutput(), ); test('when checking if analytics is enabled then false is returned.', () { @@ -78,6 +79,7 @@ void main() { 'test', 'this is a test cli', onAnalyticsEvent: (event) {}, + messageOutput: const MessageOutput(), ); test('when checking if analytics is enabled then true is returned.', () { @@ -96,6 +98,7 @@ void main() { 'test', 'this is a test cli', onAnalyticsEvent: (event) => events.add(event), + messageOutput: const MessageOutput(), ); assert(runner.analyticsEnabled()); }); @@ -208,6 +211,7 @@ void main() { 'test', 'this is a test cli', onAnalyticsEvent: (event) => events.add(event), + messageOutput: const MessageOutput(), )..addCommand(MockCommand()); assert(runner.analyticsEnabled()); }); @@ -272,6 +276,7 @@ void main() { 'test', 'this is a test cli', onAnalyticsEvent: (event) => events.add(event), + messageOutput: const MessageOutput(), )..addCommand(command); assert(runner.analyticsEnabled()); }); diff --git a/test/better_command_runner/default_flags_test.dart b/test/better_command_runner/default_flags_test.dart index c2d715e..e8792a6 100644 --- a/test/better_command_runner/default_flags_test.dart +++ b/test/better_command_runner/default_flags_test.dart @@ -7,6 +7,7 @@ void main() { 'test', 'test description', onAnalyticsEvent: (event) {}, + messageOutput: const MessageOutput(), ); test( diff --git a/test/better_command_runner/logging_test.dart b/test/better_command_runner/logging_test.dart index e7abfa0..be8ce77 100644 --- a/test/better_command_runner/logging_test.dart +++ b/test/better_command_runner/logging_test.dart @@ -34,8 +34,8 @@ void main() { 'test', 'this is a test cli', messageOutput: MessageOutput( - logUsageException: (e) => errors.add(e.toString()), - logUsage: (u) => infos.add(u), + usageExceptionLogger: (e) => errors.add(e.toString()), + usageLogger: (u) => infos.add(u), ), )..addCommand(MockCommand()); tearDown(() { @@ -74,7 +74,7 @@ void main() { expect( errors.first, contains( - 'Unexpected positional argument(s): \'this it not a valid command\'', + 'Could not find a command named "this it not a valid command".', ), ); }); diff --git a/test/better_command_runner/parse_log_level_test.dart b/test/better_command_runner/parse_log_level_test.dart index 90457ff..ae34a3c 100644 --- a/test/better_command_runner/parse_log_level_test.dart +++ b/test/better_command_runner/parse_log_level_test.dart @@ -27,6 +27,7 @@ void main() { var runner = BetterCommandRunner( 'test', 'this is a test cli', + messageOutput: const MessageOutput(), setLogLevel: ({ required CommandRunnerLogLevel parsedLogLevel, String? commandName, diff --git a/test/better_command_test.dart b/test/better_command_test.dart index aa2323b..6c5ba0c 100644 --- a/test/better_command_test.dart +++ b/test/better_command_test.dart @@ -50,7 +50,7 @@ void main() { var infos = []; var analyticsEvents = []; var messageOutput = MessageOutput( - logUsage: (u) => infos.add(u), + usageLogger: (u) => infos.add(u), ); var betterCommand = MockCommand( @@ -149,7 +149,7 @@ void main() { var infos = []; var analyticsEvents = []; var messageOutput = MessageOutput( - logUsage: (u) => infos.add(u), + usageLogger: (u) => infos.add(u), ); var betterCommand = MockCommand( @@ -199,7 +199,7 @@ void main() { 'without analytics set up and default global options', () { var infos = []; var messageOutput = MessageOutput( - logUsage: (u) => infos.add(u), + usageLogger: (u) => infos.add(u), ); var betterCommand = MockCommand( @@ -248,7 +248,7 @@ void main() { 'with additional global options', () { var infos = []; var messageOutput = MessageOutput( - logUsage: (u) => infos.add(u), + usageLogger: (u) => infos.add(u), ); var betterCommand = MockCommand(